Skip to content

Webhooks ​

Webhooks notify you in real time when something changes on a payment, refund, chargeback, or payout — without polling GET /v1/payments/{id}.

Setting up an endpoint ​

Webhook endpoints are registered from the Paytami dashboard (dash.paytami.com), in your merchant's Webhooks section:

  1. Provide an https:// URL on your server that accepts POST requests.
  2. Choose which events to subscribe to (or leave it empty to receive everything — see the full catalog).
  3. Save the secret shown at creation — it's shown once. You'll use it to verify each delivery's signature (see below).

From that same section you can see each endpoint's delivery history, manually retry a specific delivery, rotate the secret, or deactivate the endpoint.

Verifying the signature ​

Every delivery carries the Paytami-Signature header:

Paytami-Signature: t=1735689600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t: the Unix timestamp (seconds) when it was signed.
  • v1: the HMAC-SHA256 of "{t}.{raw_body}", keyed with your secret.

During a secret rotation, the header may carry two v1= values (one with the current secret, one with the previous, while the grace period is still active) — either one matching is enough.

Always verify against the raw body

The HMAC is computed over the exact bytes of the body. If your framework already parsed the JSON before you can access the original text, the signature won't match even if the content is identical — make sure you read the raw body before any parsing middleware runs.

Example (Node.js) ​

js
import { createHmac, timingSafeEqual } from 'node:crypto'

function verifyPaytamiSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('='))
  )
  // The header may carry more than one "v1" during a secret rotation —
  // use header.split(',').filter(p => p.startsWith('v1=')) if you need to
  // check all of them.
  const timestamp = Number(parts.t)
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  const received = Buffer.from(parts.v1)
  const expectedBuf = Buffer.from(expected)
  return received.length === expectedBuf.length && timingSafeEqual(received, expectedBuf)
}

Reject any delivery whose signature doesn't verify, and any whose t falls outside a reasonable tolerance window (protection against replaying a captured request).

Retries ​

If your endpoint doesn't respond 2xx, Paytami retries with increasing backoff: after 1, 5, 30, 120, 300, 600, and 1440 minutes — up to 8 attempts total. After the last attempt, the delivery is marked exhausted and you can retry it manually from the dashboard.

  • Respond 2xx as soon as you've received the event — process its effect (activating access, sending an email, etc.) afterward, asynchronously. A slow endpoint risks Paytami treating the delivery as failed on timeout.
  • A timeout, DNS error, TLS failure, or refused connection counts as a failed attempt, same as a 5xx.

Idempotency on your side ​

Every delivery carries the Paytami-Event-Id header with the event's public id (evt_...). The same event can arrive more than once — from a retry, or from a manual replay in the dashboard — so your handler needs to be idempotent: store the event ids you've already processed and discard duplicates before applying their effect again.

See also ​

Official Paytami Documentation