Skip to content

Webhooks

Webhooks are HTTPS addresses we send events to as they happen, usually within a couple of seconds of the change. Use them to learn how a payment went without polling: a customer coming back to your return_url proves nothing, an event does.

Deliveries arrive at least once and in no guaranteed order, so deduplicate on the event's id, also sent as the Webhook-Id header, and go by the object's status rather than by the order events arrive in. When in doubt, retrieve the object.

An endpoint belongs to your account's test mode or live mode, the mode of the key that made it, and is sent only that mode's events.

Terminal window
curl https://api.example.com/v1/webhook_endpoints \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example.com/webhooks",
"enabled_events": ["payment_intent.succeeded", "payment_intent.payment_failed"]
}'
{
"id": "we_3f9a1c2b7d4e5f60718293a4",
"object": "webhook_endpoint",
"url": "https://shop.example.com/webhooks",
"enabled_events": ["payment_intent.succeeded", "payment_intent.payment_failed"],
"status": "enabled",
"secret": "whsec_...",
"livemode": false,
"created": 1790411400
}
  • The endpoint's signing secret is returned on this response only, and again when you rotate it: store it where your receiver can read it. The answer is sent with Cache-Control: no-store.
  • The URL must be https:// and resolve only to public addresses; an address on a private network is refused with validation_failure on url.
  • enabled_events takes the event types that are sent today, those under Event types below. A type that is not sent yet is refused with validation_failure until it ships, so a subscription is never accepted and then sent nothing. ["*"] subscribes to every type, including those added later; * stands alone and is refused beside named types.

The calls are Create a webhook endpoint, List webhook endpoints, Update a webhook endpoint, Disable a webhook endpoint and Rotate a webhook endpoint's secret.

Every event is one JSON object, the same whatever its type:

Field
idDeduplicate on this; the same event may arrive more than once.
objectAlways event.
typeWhat happened, such as payment_intent.succeeded.
createdA Unix timestamp.
api_versionAlways v1.
livemodeWhether it is a live-mode event.
tenant_id, merchant_idWhose event it is.
data.objectThe object as the API shows it, as of this event.

Each delivery is a POST of the raw JSON body with a Webhook-Id (the event's id) and a Webhook-Signature header. An example, as the body arrives:

{"id":"evt_01j9x4b2c3d4e5f6g7h8j9k0m1","object":"event","type":"payment_intent.succeeded","created":1790434850,"api_version":"v1","livemode":false,"tenant_id":"7d2e4f61-3a8b-4c95-b0e7-2f1a9c6d8e43","merchant_id":"c4a91e2b-6f37-4d08-9b5c-e0d2f8a17b36","data":{"object":{"id":"pi_01j9x2m8k4q7r5t3v6w8y0z2a4","object":"payment_intent","amount":10000,"amount_received":10000,"currency":"php","payment_method_types":["qrph"],"channel":"ecommerce","status":"succeeded","next_action":null,"last_payment_error":null,"cancellation_reason":null,"canceled_at":null,"description":"Order 1042","metadata":{"order_id":"1042"},"livemode":false,"created":1790434700}}}

Each event's data.object is the object as the API shows it, as of the event. One difference: while a payment intent has no method, its payment_method_types in an event are the methods you allowed at creation, not the ones it can be paid by now, since those are worked out only when an intent is read. It is null when you allowed any. An event never carries a hosted checkout link or an embedded checkout client token: read the intent for them.

EventWhen
payment_intent.createdA payment intent was created. Its next_action is null even when it was created without a method.
payment_intent.requires_actionThe customer has something to do. Sent when an intent is confirmed, and when one is created without a method to wait at hosted checkout or in embedded checkout; for that one next_action is null.
payment_intent.processingThe customer acted and the outcome is not known yet.
payment_intent.succeededPaid. For cash, once the cash was paid.
payment_intent.payment_failedAn attempt to pay failed: declined, failed, not paid in time, or left by the customer. The intent is back in requires_payment_method, with last_payment_error.
payment_intent.canceledThe payment intent was canceled.
refund.created, refund.updated, refund.succeeded, refund.failedA refund was made, is on its way, reached the customer, or could not be made (failure_reason). See Refunds.
dispute.created, dispute.updated, dispute.closedA chargeback arrived, a dispute changed, a dispute was decided. See Disputes.

Any 2xx within 10 seconds counts as delivered. Anything else, or no answer, is retried: 12 attempts over about three days (after 1, 5, 15 and 30 minutes, then 1, 2, 4, 8, 12, 16 and 24 hours), after which the delivery fails. Redirects are not followed.

An endpoint that keeps failing is turned off

Section titled “An endpoint that keeps failing is turned off”

When every delivery to an endpoint has failed for five days in a row, with none succeeding in between, we set its status to disabled and email the people on your team with the Developers permission. Nothing is sent to it until you update its status to enabled (Update a webhook endpoint); resend what it missed after that from the Merchant Dashboard. Deliveries that failed while it was disabled stay failed.

You can also stop an endpoint yourself with Disable a webhook endpoint: deliveries still waiting to be tried fail when they are.

Each delivery is signed in a Webhook-Signature header, so you can tell it came from us:

Webhook-Signature: t=1790434852,v1=ba1753e225757778aff55cb9a81cac1eb2a5f0724c32055eb8fb5e6285d54094

Compute HMAC-SHA256 of <t>.<raw request body> with your endpoint's secret, and compare it in constant time with each v1: during a secret rotation there is one per secret, and any match is genuine. Reject a t more than five minutes old, so a captured request cannot be replayed. Verify against the raw body, before parsing it.

These values are made up for this example, and the signature below was computed from them. The secret is whsec_Kf3vQ9m2Xc7LpR1tYb8dNw5sUa0eHj4ZgTq6iVoMxCk, the timestamp t is 1790434852, and the raw body is the event above, exactly as shown, with no spaces or line breaks. With openssl:

Terminal window
SECRET='whsec_Kf3vQ9m2Xc7LpR1tYb8dNw5sUa0eHj4ZgTq6iVoMxCk'
T=1790434852
BODY='{"id":"evt_01j9x4b2c3d4e5f6g7h8j9k0m1","object":"event","type":"payment_intent.succeeded","created":1790434850,"api_version":"v1","livemode":false,"tenant_id":"7d2e4f61-3a8b-4c95-b0e7-2f1a9c6d8e43","merchant_id":"c4a91e2b-6f37-4d08-9b5c-e0d2f8a17b36","data":{"object":{"id":"pi_01j9x2m8k4q7r5t3v6w8y0z2a4","object":"payment_intent","amount":10000,"amount_received":10000,"currency":"php","payment_method_types":["qrph"],"channel":"ecommerce","status":"succeeded","next_action":null,"last_payment_error":null,"cancellation_reason":null,"canceled_at":null,"description":"Order 1042","metadata":{"order_id":"1042"},"livemode":false,"created":1790434700}}}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET"
ba1753e225757778aff55cb9a81cac1eb2a5f0724c32055eb8fb5e6285d54094

That is the v1 in the header above. Change one character of the body, or use another secret, and it no longer matches. The timestamp of this example is long past, so a receiver would reject it as too old: leave that check out when you try it by hand.

In Python, with the standard library, this is the whole check for a receiver:

import hashlib, hmac, time
def verify(header: str, body: bytes, secret: str) -> bool:
parts = [part.split("=", 1) for part in header.split(",")]
t = next(value for key, value in parts if key == "t")
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, value) for key, value in parts if key == "v1")

Rotate a webhook endpoint's secret issues a new signing secret, returned on that response only. For 24 hours deliveries carry a signature made with each, so your receiver can switch over without dropping an event: accept any v1 that matches either secret, and drop the old secret after the day is up.