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#
| Event | Sent when |
|---|---|
payment.succeeded | A payment link or QR code payment was collected. A subscription charge sends this too. |
payment.failed | A checkout attempt was declined or did not complete, or a subscription renewal failed. |
payment.refunded | A full or partial refund was executed. |
payout.completed | A payout to your settlement account was confirmed. |
payout.failed | A 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)
}
import hashlib, hmac, time
def verify_olympus_webhook(raw_body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
timestamp = headers.get("x-olympus-timestamp")
signature = headers.get("x-olympus-signature")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
<?php
function verifyOlympusWebhook(string $rawBody, array $headers, string $secret, int $tolerance = 300): bool {
$timestamp = $headers['x-olympus-timestamp'] ?? null;
$signature = $headers['x-olympus-signature'] ?? null;
if (!$timestamp || !$signature) return false;
if (abs(time() - (int)$timestamp) > $tolerance) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}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
typetogether withdata.id. Make handlers idempotent. - Events can arrive out of order. A
payment.refundedcould reach you before a retriedpayment.succeeded. UseGET /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
2xxonly 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.