Events & webhooks
Webhooks notify. Reads recover.
Events can arrive more than once and out of order. Deduplicate by event ID, and ignore an event whose object version is older than the one you already have.
{
"id": "evt_01K0A9…",
"type": "payment_intent.succeeded",
"event_version": 1,
"api_version": "2026-07-15",
"data": {
"object": {
"id": "pi_01K0A9…",
"amount": 2500,
"currency": "USD",
"status": "succeeded",
"version": 4
}
}
}Set up your endpoint and check that it works
- 1Add an endpoint
Use Add endpoint in Developers → Webhooks, or POST
/v1/webhook_endpoints. Subscribe it to the events you act on, such aspayment_intent.succeededandpayment_intent.failed. - 2Store the signing secret
It is shown once, when you add the endpoint or rotate its secret. Keep it in your server's secret store.
- 3Check that it works
Create a payment with
+263 77 000 0001, as in quickstart step 3, then open the endpoint's Deliveries to see the delivery and the HTTP status your server answered. - 4Developing locally?
Expose your endpoint over HTTPS with a tunnel, or read
GET /v1/eventsinstead of waiting for deliveries.
Verify the signature before you trust a delivery
Each event arrives as a POST with a JSON body and the headers below. The signature covers the delivery ID and the exact bytes of the body, so check it before you parse or store anything.
| Header | What it carries |
|---|---|
| Perion-Signature | t= with the signing time in Unix seconds, then one v1= entry for each active signing secret, each a hex-encoded HMAC-SHA-256. |
| Perion-Delivery-Id | The ID of this delivery. It is part of the signed payload. |
| Perion-Event-Id | The event's id, the same value as in the body. Deduplicate on it. |
- 1Rebuild the signed payload
Join the
tvalue, thePerion-Delivery-Idheader, and the raw request body with periods:{t}.{delivery ID}.{raw body}. - 2Compare it with every v1 entry
Compute an HMAC-SHA-256 of that payload, keyed with the endpoint's whole signing secret, including its
whsec_prefix. Accept the delivery if the hex digest matches anyv1entry, using a constant-time comparison. - 3Reject old signatures
Reject a
tmore than five minutes (300 seconds) from your server's clock. Every delivery attempt is signed when it is sent, so a retry carries a newt.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the exact request bytes (a Buffer), read before any JSON parsing.
// secrets: your current signing secret, plus the previous one during a rotation.
export function verifyPerionSignature(rawBody, headers, secrets, toleranceSeconds = 300) {
const entries = String(headers["perion-signature"] ?? "").split(",").map((entry) => entry.trim().split("="));
const timestamp = Number(entries.find(([key]) => key === "t")?.[1]);
const signatures = entries.filter(([key]) => key === "v1").map(([, value]) => Buffer.from(value ?? "", "hex"));
const deliveryId = String(headers["perion-delivery-id"] ?? "");
if (!Number.isSafeInteger(timestamp) || signatures.length === 0 || !deliveryId) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.${deliveryId}.`), rawBody]);
return secrets.some((secret) => {
const expected = createHmac("sha256", secret).update(signedPayload).digest();
return signatures.some((candidate) => candidate.length === expected.length && timingSafeEqual(candidate, expected));
});
}Rotate a signing secret without missing a delivery.
Rotating a signing secret returns the new secret once. Until previous_secret_expires_at (24 hours by default; you can choose from 5 minutes to 72 hours), each delivery carries two v1 entries, one for each secret. Accept either while the overlap lasts, then delete the old secret.
Respond with a 2xx status within 10 seconds, once the event is stored. Any other status, a redirect, or a timeout counts as a failed attempt: the delivery is retried with growing delays, up to six hours apart, for 72 hours. After that the delivery shows Stopped retrying; replay it from the endpoint's deliveries, or read it from GET /v1/events.
Event types
Subscribe an endpoint to exact event types, or to every type for one object with a wildcard such as payment_intent.*. These are all 83 event types an endpoint can subscribe to.
Match the exact string.
Machine names differ: payment intents use payment_intent.cancelled; subscriptions and collection cycles use subscription.canceled and collection_cycle.canceled. Don't normalize the spelling in a filter.
| Object | Event types |
|---|---|
| agreement |
|
| api_client |
|
| billing_test_clock |
|
| checkout_session |
|
| collection_cycle |
|
| invoice |
|
| merchant |
|
| payment_intent |
|
| payment_link |
|
| plan |
|
| reconciliation |
|
| refund |
|
| settlement |
|
| subscription |
|
| webhook_endpoint |
|