Skip to content
PerionPayments

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.

json
{
  "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
    }
  }
}

Verify the signature over the raw request body Return 2xx only after you have stored the event Replay missed events from the endpoint's deliveries

Set up your endpoint and check that it works

  1. Add an endpoint

    Use Add endpoint in Developers → Webhooks, or POST /v1/webhook_endpoints. Subscribe it to the events you act on, such as payment_intent.succeeded and payment_intent.failed.

  2. Store the signing secret

    It is shown once, when you add the endpoint or rotate its secret. Keep it in your server's secret store.

  3. Check 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.

  4. Developing locally?

    Expose your endpoint over HTTPS with a tunnel, or read GET /v1/events instead 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.

HeaderWhat it carries
Perion-Signaturet= 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-IdThe ID of this delivery. It is part of the signed payload.
Perion-Event-IdThe event's id, the same value as in the body. Deduplicate on it.
  1. Rebuild the signed payload

    Join the t value, the Perion-Delivery-Id header, and the raw request body with periods: {t}.{delivery ID}.{raw body}.

  2. Compare 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 any v1 entry, using a constant-time comparison.

  3. Reject old signatures

    Reject a t more than five minutes (300 seconds) from your server's clock. Every delivery attempt is signed when it is sent, so a retry carries a new t.

javascript
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.

ObjectEvent types
agreement
  • agreement.activated
  • agreement.created
  • agreement.digest_ready
  • agreement.expired
  • agreement.paused
  • agreement.replaced
  • agreement.resumed
  • agreement.revoked
  • agreement.suspended
api_client
  • api_client.created
  • api_client.expiring
  • api_client.paused
  • api_client.resumed
  • api_client.revoked
  • api_client.secret_rotated
billing_test_clock
  • billing_test_clock.advanced
  • billing_test_clock.created
checkout_session
  • checkout_session.completed
  • checkout_session.expired
collection_cycle
  • collection_cycle.canceled
  • collection_cycle.collection_paused
  • collection_cycle.declined
  • collection_cycle.evidence_exhausted
  • collection_cycle.failed
  • collection_cycle.grace_started
  • collection_cycle.inflight_preserved
  • collection_cycle.notice_sent
  • collection_cycle.paid
  • collection_cycle.payer_action_required
  • collection_cycle.pending_evidence
  • collection_cycle.prompted
  • collection_cycle.suspended
  • collection_cycle.uncollectible
invoice
  • invoice.collecting
  • invoice.open
  • invoice.paid
  • invoice.uncollectible
  • invoice.upcoming
merchant
  • merchant.live_access_request_answered
  • merchant.live_provisioned
  • merchant.sandbox_provisioned
  • merchant.team_invitation_accepted
  • merchant.team_invitation_created
payment_intent
  • payment_intent.amount_mismatch
  • payment_intent.assurance_updated
  • payment_intent.cancelled
  • payment_intent.created
  • payment_intent.expired
  • payment_intent.failed
  • payment_intent.processing
  • payment_intent.requires_action
  • payment_intent.requires_payment_method
  • payment_intent.succeeded
payment_link
  • payment_link.consumed
plan
  • plan.archived
  • plan.created
reconciliation
  • reconciliation.exception.opened
  • reconciliation.exception.resolved
refund
  • refund.created
  • refund.failed
  • refund.succeeded
  • refund.updated
settlement
  • settlement.created
  • settlement.failed
  • settlement.paid
  • settlement.returned
subscription
  • subscription.activated
  • subscription.canceled
  • subscription.canceled_payment_reconciled
  • subscription.created
  • subscription.cycle_advanced
  • subscription.past_due
  • subscription.paused
  • subscription.payment_source_change_failed
  • subscription.payment_source_change_requested
  • subscription.payment_source_changed
  • subscription.reconsent_requested
  • subscription.replaced
  • subscription.resumed
  • subscription.trial_ended
  • subscription.trial_started
  • subscription.unpaid
webhook_endpoint
  • webhook_endpoint.disabled
Next: AI agents