Olympus PayDevelopers

Webhooks

Olympus Pay calls an HTTPS endpoint you register whenever a payment or payout changes state, so you do not have to poll.

Register an endpoint#

curl https://merchant.olympuspay.co/api/v1/webhooks \
  -X POST \
  -H "Authorization: Bearer $OLYMPUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://api.example.com/olympus/webhooks" }'

The response includes a secret beginning whsec_. It is shown once. Store it in your secret manager. Requires scope webhooks:manage (owners and admins can also manage endpoints in the dashboard).

The URL must be https://, must resolve to a public address (not localhost, a private network or a cloud metadata address), must not contain a username or password, and must not answer with a redirect. Redirects are treated as failures.

Events#

EventSent when
payment.succeededA payment link or QR code payment was collected. A subscription charge sends this too.
payment.failedA checkout attempt was declined or did not complete, or a subscription renewal failed.
payment.refundedA full or partial refund was executed.
payout.completedA payout to your settlement account was confirmed.
payout.failedA payout to your settlement account failed.

GET /reference lists the event types currently sent.

The request we send#

POST /olympus/webhooks HTTP/1.1
Content-Type: application/json
X-Olympus-Event: payment.succeeded
X-Olympus-Timestamp: 1790000000
X-Olympus-Signature: 5b0c...e91f
{
  "type": "payment.succeeded",
  "created_at": "2026-09-26T10:15:04.512Z",
  "data": {
    "id": "6f0c1a52-3f3a-4b6e-9b0e-6d0a1c2f9a11",
    "amount": 25000,
    "currency": "BWP",
    "fee_amount": 750,
    "net_amount": 24250,
    "status": "succeeded",
    "qr_code_id": null,
    "payment_link_id": "b3a4e1c8-7d52-4a53-8c0b-2f1a9d44e8aa"
  }
}

payment.refunded adds refunded_amount (running total), refund_amount (this refund) and fully_refunded. payment.failed and payout.failed add failure_reason. Amounts are in minor units.

Verify the signature#

X-Olympus-Signature is the hex HMAC-SHA256 of the string {X-Olympus-Timestamp}.{raw request body}, keyed with your whsec_ secret. Compute it over the raw bytes of the body, before any JSON parsing, and compare in constant time. Reject requests whose timestamp is more than five minutes old, so a captured request cannot be replayed later.


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

export function verifyOlympusWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = headers['x-olympus-timestamp']
  const signature = headers['x-olympus-signature']
  if (!timestamp || !signature) return false
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
  const a = Buffer.from(signature)
  const b = Buffer.from(expected)
  return a.length === b.length && timingSafeEqual(a, b)
}

Respond quickly with any 2xx after verifying and storing the event, and do the heavy work afterwards. We wait 8 seconds for a response.

Retries and failure#

A delivery succeeds on any 2xx. Anything else (a non-2xx status, a timeout, a connection error, a redirect) is a failure. Each attempt is recorded and can be inspected with GET /webhooks/{id}/deliveries (scope webhooks:read_logs), which shows attempt_number, the status returned, next_retry_at and dead_lettered.

A failed delivery is retried automatically, up to four more times, with waits of 1, 5, 30 and 120 minutes between attempts. Retries are picked up by a scheduled sweep that currently runs once a day, so a failed delivery can take up to a day to be retried. After the fifth attempt the delivery is marked dead_lettered and is not retried again. You can resend any delivery yourself, at any time, with POST /webhooks/deliveries/{id}/retry (scope webhooks:retry). It resends the original payload with a fresh signature and timestamp.

Build your handler for these facts#

  • Events can arrive more than once. Deduplicate on the event type together with data.id. Make handlers idempotent.
  • Events can arrive out of order. A payment.refunded could reach you before a retried payment.succeeded. Use GET /payments/{id} as the source of truth when order matters.
  • Do not trust the payload alone for money. For a decision that releases goods, confirm the payment with GET /payments/{id}.
  • Return 2xx only after you have safely stored the event.

Test your endpoint#

With a sandbox key, pay and fail a test payment link (Sandbox) and watch the deliveries log. Use POST /webhooks/deliveries/{id}/retry to replay the same event while you debug. A public tunnel such as ngrok or Cloudflare Tunnel lets you receive events on your laptop.