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.
Creating an endpoint
Section titled “Creating an endpoint”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.
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
secretis returned on this response only, and again when you rotate it: store it where your receiver can read it. The answer is sent withCache-Control: no-store. - The URL must be
https://and resolve only to public addresses; an address on a private network is refused withvalidation_failureonurl. enabled_eventstakes the event types that are sent today, those under Event types below. A type that is not sent yet is refused withvalidation_failureuntil 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.
The event envelope
Section titled “The event envelope”Every event is one JSON object, the same whatever its type:
| Field | |
|---|---|
id | Deduplicate on this; the same event may arrive more than once. |
object | Always event. |
type | What happened, such as payment_intent.succeeded. |
created | A Unix timestamp. |
api_version | Always v1. |
livemode | Whether it is a live-mode event. |
tenant_id, merchant_id | Whose event it is. |
data.object | The 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}}}Event types
Section titled “Event types”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.
| Event | When |
|---|---|
payment_intent.created | A payment intent was created. Its next_action is null even when it was created without a method. |
payment_intent.requires_action | The 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.processing | The customer acted and the outcome is not known yet. |
payment_intent.succeeded | Paid. For cash, once the cash was paid. |
payment_intent.payment_failed | An 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.canceled | The payment intent was canceled. |
refund.created, refund.updated, refund.succeeded, refund.failed | A refund was made, is on its way, reached the customer, or could not be made (failure_reason). See Refunds. |
dispute.created, dispute.updated, dispute.closed | A chargeback arrived, a dispute changed, a dispute was decided. See Disputes. |
Answering, and retries
Section titled “Answering, and retries”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.
Verifying a signature
Section titled “Verifying a signature”Each delivery is signed in a Webhook-Signature header, so you can tell it came from us:
Webhook-Signature: t=1790434852,v1=ba1753e225757778aff55cb9a81cac1eb2a5f0724c32055eb8fb5e6285d54094Compute 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.
A worked example
Section titled “A worked example”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:
SECRET='whsec_Kf3vQ9m2Xc7LpR1tYb8dNw5sUa0eHj4ZgTq6iVoMxCk'T=1790434852BODY='{"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"ba1753e225757778aff55cb9a81cac1eb2a5f0724c32055eb8fb5e6285d54094That 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")Rotating the secret
Section titled “Rotating the secret”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.