Receiving webhooks
Qasimha sends a signed POST webhook to your endpoint whenever an order status changes, allowing your system to stay synchronized in real time without the need for continuous polling.
Use webhooks to immediately detect key order lifecycle events, including when a checkout is:
- Completed successfully
- Declined
- Expired
You will also receive notifications when an order-related action is recorded, such as:
- Shipment creation or updates
- Refunds
- Order cancellations
Each webhook payload includes the relevant order identifier, enabling your application to retrieve the latest order details and trigger the appropriate business workflows.
ImportantWebhooks should be treated as event notifications, not the source of truth. Before fulfilling an order, issuing a refund, or taking any irreversible action, always verify the current order status by calling.
Before you start
| You need | Details |
|---|---|
| A public HTTPS URL | Reachable from the internet, with a valid certificate. Private, local and link-local addresses are refused. |
| A secret API key | sk_test_... (UAT) or sk_live_... (production) with the webhooks:manage scope, if you register endpoints with the API. See Authentication. |
| Raw-body access | Your framework must give you the request body exactly as received, so you can verify the signature. |
| A store for processed events | A table or cache of event ids you have handled, to remove duplicates. |
| Environment | API host | Merchant Portal |
|---|---|---|
| UAT | https://api-uat.qasimha.com | https://portal-uat.qasimha.com |
| Production | https://api.qasimha.com | https://portal.qasimha.com |
Register an endpoint
Register in the Merchant Portal → Settings → Webhooks, or with the API (scope webhooks:manage):
| Call | Purpose |
|---|---|
POST /api/v1/merchant/webhooks | Register an endpoint; returns its signing secret once |
GET /api/v1/merchant/webhooks | List endpoints; secrets are never returned, only secretHint |
GET /api/v1/merchant/webhooks/{id} | Read one endpoint |
PUT /api/v1/merchant/webhooks/{id} | Change the URL, description, events, or active flag |
DELETE /api/v1/merchant/webhooks/{id} | Stop all deliveries to the endpoint |
POST /api/v1/merchant/webhooks/{id}/rotate-secret | Issue a new signing secret |
POST /api/v1/merchant/webhooks/{id}/ping | Send a signed ping test event |
GET /api/v1/merchant/webhooks/{id}/deliveries | Delivery log, with every attempt |
POST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replay | Send an event again |
- You can register up to 5 endpoints per account. Paused endpoints count toward the limit.
- Each endpoint has its own signing secret (
whsec_...). - Each endpoint can receive every event (leave out
events) or only a list you choose. - The URL must be public HTTPS, at most 1024 characters, with no user name, password or
#fragment. Private or internal addresses are rejected (URL_PRIVATE). The same check runs again on every delivery, so a host that later resolves to a private address is not called. - Register separate endpoints in UAT and production. Each environment has its own endpoints and secrets.
- Every route under
/merchant/webhooks, including the reads, needs thewebhooks:managescope on an API key. In the portal, only an owner or admin can manage webhooks. - Every change (create, update, delete, rotate, replay) is written to your account's audit log.
Create an endpoint
curl -X POST "https://api-uat.qasimha.com/api/v1/merchant/webhooks" \
-H "X-Api-Key: $QASIMHA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example/webhooks/qasimha",
"description": "Production store",
"events": ["order.confirmed", "order.refunded", "order.partially_refunded"]
}'| Field | Type | Required | Notes |
|---|---|---|---|
url | string | Yes | Public https:// URL, up to 1024 characters. |
description | string | No | Up to 200 characters. |
events | string[] | No | Event types from the Events table. Leave it out, or send [], to receive every event. Duplicates are dropped. |
The response is 201 Created with code WEBHOOK_CREATED. secret appears in this response only. Store it in a secrets manager right away.
{
"success": true,
"uuid": "9b2016cc-4c8a-453a-9185-46220744cb18",
"code": "WEBHOOK_CREATED",
"message": "Webhook endpoint created",
"data": {
"id": "a81cce0b-35c7-45e0-a212-0c9f0969ddf4",
"url": "https://shop.example/webhooks/qasimha",
"description": "Production store",
"events": ["order.confirmed", "order.refunded", "order.partially_refunded"],
"isActive": true,
"secret": "whsec_...b877",
"secretHint": "b877",
"previousSecretExpiresAt": null,
"createdAt": "2026-09-25T23:42:29.5562693Z"
},
"errors": null
}Endpoint fields
GET /api/v1/merchant/webhooks returns data as an array of these objects, newest first. GET /api/v1/merchant/webhooks/{id} returns one.
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | Endpoint id |
url | string | Where deliveries go |
description | string or null | Your label |
events | string[] | Subscribed events. [] means every event. |
isActive | boolean | false pauses the endpoint: new events are not sent to it |
secretHint | string | Last 4 characters of the current secret, so you can tell which secret a server holds |
previousSecretExpiresAt | string or null | After a rotation, when the old secret stops signing deliveries |
createdAt | string (UTC) | When the endpoint was registered |
updatedAt | string or null | Last change. Not present in the create and rotate responses. |
secret | string | Create and rotate responses only. Never returned again. |
Update, pause or delete
PUT /api/v1/merchant/webhooks/{id} takes any of url, description, events and isActive. Fields you leave out keep their value. events: [] switches the endpoint to every event. The response code is WEBHOOK_UPDATED.
# Pause an endpoint during maintenance
curl -X PUT "https://api-uat.qasimha.com/api/v1/merchant/webhooks/a81cce0b-35c7-45e0-a212-0c9f0969ddf4" \
-H "X-Api-Key: $QASIMHA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "isActive": false }'Events that happen while an endpoint is paused are not queued for it. When you resume, read missed changes with the orders API.
DELETE /api/v1/merchant/webhooks/{id} removes the endpoint (code WEBHOOK_DELETED, data: null). Deliveries still waiting for a retry end as Failed.
Rotate the secret
POST /api/v1/merchant/webhooks/{id}/rotate-secret returns the endpoint with a new secret (code SECRET_ROTATED). For 24 hours, until previousSecretExpiresAt, every delivery is signed with both secrets. See Rotating the secret.
Send a test event
curl -X POST "https://api-uat.qasimha.com/api/v1/merchant/webhooks/a81cce0b-35c7-45e0-a212-0c9f0969ddf4/ping" \
-H "X-Api-Key: $QASIMHA_API_KEY"The response is 202 Accepted with code PING_QUEUED and data: { "eventId": "evt_...", "endpointId": "..." }. A signed ping event then arrives at the URL. The portal's Send test event button does the same. A ping is sent even to a paused endpoint.
Read the delivery log
curl "https://api-uat.qasimha.com/api/v1/merchant/webhooks/a81cce0b-35c7-45e0-a212-0c9f0969ddf4/deliveries?status=Failed&page=1&pageSize=20" \
-H "X-Api-Key: $QASIMHA_API_KEY"| Query | Default | Notes |
|---|---|---|
status | all | Pending, Succeeded or Failed |
page | 1 | 1 or greater |
pageSize | 20 | Up to 100; larger values are capped at 100 |
The response is data.data (rows, newest first) and data.pagination (totalItems, page, pageSize, totalPages). One row is one event sent to one endpoint, with all its attempts:
| Field | Meaning |
|---|---|
id | Delivery id. Use it to replay. |
eventId | The event as a UUID. The evt_... id in the body and headers is evt_ + this UUID without dashes. |
eventType | For example order.confirmed or ping |
orderId | The Qasimha order id; null for ping |
orderReferenceId | Your order reference; null for ping |
status | Pending (waiting for its next attempt), Succeeded (your server answered 2xx) or Failed (every attempt failed, or the endpoint was deleted) |
attempts | Attempts made so far |
maxAttempts | Attempts allowed for this delivery |
nextAttemptAt | When the next retry is due; null when none is left |
lastAttemptAt | When the last attempt finished |
lastStatusCode | HTTP status your server returned; null on a timeout or connection error |
lastError | For example HTTP 500, Timed out after 15s, or the connection error |
responseSnippet | The first 1000 characters of your server's response body |
durationMs | How long the last attempt took |
succeededAt | When your server accepted it |
replayOfId | Set on a replay: the delivery it repeats |
createdAt | When the delivery was created |
The delivery log keeps finished deliveries for 30 days.
Replay a delivery
curl -X POST "https://api-uat.qasimha.com/api/v1/merchant/webhooks/a81cce0b-35c7-45e0-a212-0c9f0969ddf4/deliveries/6e44f7d4-a878-4e1c-9148-e7f8745fb50d/replay" \
-H "X-Api-Key: $QASIMHA_API_KEY"The response is 202 Accepted with code DELIVERY_REPLAYED and the new delivery row (replayOfId set, attempts: 0). The original row is kept. The replay sends the same event, with the same id and body, and gets its own full set of retries. Use it after you fix your receiver.
With a server SDK
The server SDKs wrap every call above:
| Operation | Node.js (qasimha.webhookEndpoints) | PHP ($qasimha->webhookEndpoints()) | .NET (client.WebhookEndpoints) | Java (client.webhookEndpoints()) |
|---|---|---|---|---|
| Register | create | create | CreateAsync | create |
| List | list | list | ListAsync | list |
| Read one | get | get | GetAsync | get |
| Update | update | update | UpdateAsync | update |
| Delete | delete | delete | DeleteAsync | delete |
| Rotate secret | rotateSecret | rotateSecret | RotateSecretAsync | rotateSecret |
| Test event | ping | ping | PingAsync | ping |
| Delivery log | listDeliveries | listDeliveries | ListDeliveriesAsync | listDeliveries |
| Replay | replayDelivery | replayDelivery | ReplayDeliveryAsync | replayDelivery |
const endpoint = await qasimha.webhookEndpoints.create({
url: 'https://shop.example/webhooks/qasimha',
description: 'Production store',
events: ['order.confirmed', 'order.refunded'], // omit, or [], for every event
});
await secrets.put('QASIMHA_WEBHOOK_SECRET', endpoint.secret); // whsec_..., shown once
await qasimha.webhookEndpoints.ping(endpoint.id);
const log = await qasimha.webhookEndpoints.listDeliveries(endpoint.id, { status: 'Failed', page: 1, pageSize: 20 });
await qasimha.webhookEndpoints.replayDelivery(endpoint.id, log.items[0].id);Management errors
| Status | Code | Cause |
|---|---|---|
| 400 | URL_REQUIRED | url is missing or empty |
| 400 | URL_INVALID | Not an absolute URL with a valid host |
| 400 | URL_SCHEME | Not https:// |
| 400 | URL_PRIVATE | The host is a private, local or link-local address |
| 400 | URL_TOO_LONG | Longer than 1024 characters |
| 400 | URL_USERINFO | The URL contains a user name or password |
| 400 | URL_FRAGMENT | The URL contains a #fragment |
| 400 | INVALID_EVENT | An entry in events is not a known event type |
| 400 | LIMIT_EXCEEDED | The account already has 5 endpoints |
| 400 | VALIDATION_ERROR | description is longer than 200 characters, or page / pageSize is out of range |
| 400 | INVALID_STATUS | status filter is not Pending, Succeeded or Failed |
| 401 | — | Missing, invalid or revoked API key |
| 403 | FORBIDDEN | The key lacks webhooks:manage, or the portal user is not an owner or admin |
| 404 | NOT_FOUND | No such endpoint or delivery on your account |
Events
| Event | When | amount in the body | Usual status |
|---|---|---|---|
order.confirmed | Checkout finished: the customer paid the first instalment and signed the contract | Order total | Confirmed |
order.declined | Declined, or the customer abandoned it on the confirmation call | Order total | Declined |
order.fulfilled | You recorded a shipment and the whole order is now shipped | That shipment | FullyFulfilled |
order.partially_fulfilled | You recorded a shipment and part of the order is still unshipped | That shipment | PartiallyFulfilled |
order.refunded | A refund was recorded and the whole order is now refunded | That refund | FullyReversed |
order.partially_refunded | A refund was recorded and part of the order is still not refunded | That refund | PartiallyReversed |
order.voided | The order was voided | What was released | Voided |
order.cancelled | You cancelled an open checkout | Order total | Cancelled |
order.expired | The customer never completed checkout | Order total | Expired |
checkout_link.expired | The same, for an order created from a payment link or QR code | Order total | Expired |
ping | Test event from the portal or the ping endpoint | Not in the body | Not in the body |
- Each refund and each shipment is its own event, even when a second partial refund leaves the status unchanged.
- Events are created in the same database transaction as the order change, so you are never told about a change that was rolled back.
- You subscribe by event name.
pingis always sent to the endpoint it targets, whatevereventssays.
order.completedis being retired
order.completedis accepted as an alias fororder.confirmedfor one release only. Subscribe toorder.confirmed.
Payload
Every delivery is a POST with Content-Type: application/json; charset=utf-8. The body is the same on every retry and replay of an event.
{
"id": "evt_9f8c1d2e3a4b5c6d7e8f90a1b2c3d4e5",
"type": "order.confirmed",
"createdAt": "2026-09-25T09:30:00Z",
"apiVersion": "v1",
"orderId": "9a2e6c4b-1111-4c2d-8a1f-000000000001",
"orderReferenceId": "MERCHANT-ORDER-12345",
"status": "Confirmed",
"amount": 1000.00,
"currency": "SAR",
"data": {
"order": {
"id": "9a2e6c4b-1111-4c2d-8a1f-000000000001",
"orderReferenceId": "MERCHANT-ORDER-12345",
"status": "Confirmed",
"channel": "Online",
"checkoutChannel": null,
"totalAmount": 1000.00,
"currencyCode": "SAR",
"capturedAmount": 0,
"refundedAmount": 0,
"voidedAmount": 0,
"instalments": 4,
"createdAt": "2026-09-25T09:00:00Z",
"confirmedAt": "2026-09-25T09:30:00Z"
}
}
}| Field | Type | Meaning |
|---|---|---|
id | string | Event id, evt_ + 32 hex characters. The same on every retry and replay. Remove duplicates with it. |
type | string | Event type, as in the Events table |
createdAt | string (UTC) | When the event happened. It does not change between retries. |
apiVersion | string | Payload version, v1 |
orderId | string (UUID) | The Qasimha order id |
orderReferenceId | string | Your order reference |
status | string | Order status after the change |
amount | number | The amount this event is about. See the Events table. |
currency | string | ISO 4217 code, SAR |
data.order.id | string (UUID) | Same as orderId |
data.order.orderReferenceId | string | Same as orderReferenceId |
data.order.status | string | Same as status |
data.order.channel | string | Online, InStore or QrCode |
data.order.checkoutChannel | string or null | How a hosted checkout started, for example Link or Qr for a payment link or QR code |
data.order.totalAmount | number | Order total |
data.order.currencyCode | string | SAR |
data.order.capturedAmount | number | Total shipped (fulfilled) so far |
data.order.refundedAmount | number | Total refunded so far |
data.order.voidedAmount | number | Total voided |
data.order.instalments | number | Number of instalments |
data.order.createdAt | string (UTC) | When the order was created |
data.order.confirmedAt | string or null | When checkout finished |
The order snapshot is taken when the event happens. Read the order again before you act on it.
A ping body has no order fields:
{
"id": "evt_a05fd3fddfea4ac8bf76a442e3cf441d",
"type": "ping",
"createdAt": "2026-09-25T23:42:32Z",
"apiVersion": "v1",
"data": { "endpointId": "a81cce0b-35c7-45e0-a212-0c9f0969ddf4" }
}Headers
| Header | Example | Purpose |
|---|---|---|
X-Qasimha-Signature | t=1758790800,v1=1eb16bb1… | Signature. See Verifying webhook signatures. Two v1 values for 24 hours after a secret rotation. |
X-Qasimha-Timestamp | 1758790800 | Unix seconds when this attempt was sent; same as t |
X-Qasimha-Event | order.confirmed | Event type |
X-Qasimha-Event-Id | evt_9f8c… | Event id. The same on every retry, so use it to remove duplicates. |
X-Qasimha-Delivery-Attempt | 1 | 1 on the first attempt, then 2 to 6. Starts at 1 again on a replay. |
Content-Type | application/json; charset=utf-8 | Body format |
User-Agent | Qasimha-Webhooks/1.0 | Identifies Qasimha deliveries in your logs |
Delivery and retries
- Qasimha checks for new events every 5 seconds, so a delivery usually leaves within seconds of the change.
- Respond with a
2xxstatus within 15 seconds. Do the real work in the background. - Only
2xxcounts as success. Redirects (3xx) are not followed and count as a failure, so register the final URL. - A delivery that fails (no
2xx, a timeout, or a connection error) is retried 5 times over about 24 hours, so it gets 6 attempts in total:
| Attempt | Sent |
|---|---|
| 1 | Right away |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes after attempt 2 failed |
| 4 | 30 minutes after attempt 3 failed |
| 5 | 2 hours after attempt 4 failed |
| 6 | 21 hours after attempt 5 failed |
- After the sixth failure the delivery is
Failed. It is not retried again unless you replay it. - Each attempt is signed afresh with its own timestamp, so a late retry still passes the 5-minute check.
- The portal shows every attempt and lets you replay one.
Handling events correctly
- Verify the signature first. Reject anything that fails with
400. - Respond
2xxright away, then process the event in the background. - Remove duplicates using the event
id(orX-Qasimha-Event-Id). Retries and replays repeat it. - Events can arrive out of order. Don't assume the sequence.
- Treat the event as a signal, not the source of truth. Read the order with
GET /api/v1/merchant/orders/{orderId}before you ship or refund. - Answer
2xxto event types you don't handle, so they are not retried.
Idempotent handler
Record each event id once, inside the same transaction as the work it triggers. A unique key on the id makes a second delivery a no-op.
CREATE TABLE qasimha_webhook_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);import express from 'express';
import Qasimha, { WebhookSignatureError } from '@qasimha/sdk';
const app = express();
const qasimha = new Qasimha({
apiKey: process.env.QASIMHA_API_KEY!,
webhookSecret: process.env.QASIMHA_WEBHOOK_SECRET,
});
// express.raw keeps the exact bytes Qasimha signed. Register it before any express.json().
app.post('/webhooks/qasimha', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = qasimha.webhooks.constructEvent(req.body, req.headers);
} catch (error) {
return res.sendStatus(error instanceof WebhookSignatureError ? 400 : 500);
}
res.sendStatus(200); // acknowledge first
queue.add(event); // process in the background
});
async function process(event) {
await db.transaction(async (tx) => {
const inserted = await tx.query(
'INSERT INTO qasimha_webhook_events (event_id, event_type) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[event.id, event.type],
);
if (inserted.rowCount === 0) return; // already handled
if (event.type === 'ping') return;
const order = await qasimha.orders.get(event.orderId); // the source of truth
switch (event.type) {
case 'order.confirmed':
await markPaid(tx, order);
break;
case 'order.declined':
case 'order.cancelled':
case 'order.expired':
case 'checkout_link.expired':
await releaseStock(tx, order);
break;
case 'order.refunded':
case 'order.partially_refunded':
await recordRefund(tx, order, event.amount);
break;
default:
break; // other events: nothing to do
}
});
}markPaid, releaseStock, recordRefund, queue and db stand for your own code.
Testing in UAT
- Create a UAT secret key with
webhooks:manageinhttps://portal-uat.qasimha.com. - Register your endpoint against
https://api-uat.qasimha.com. Use a public HTTPS tunnel if your server runs on your machine. - Call
POST /api/v1/merchant/webhooks/{id}/pingand check that your server returns200and the delivery showsSucceeded. - Run a test checkout and watch
order.confirmedarrive. Then fulfil, refund or cancel it and watch the matching events. - Return
500from your handler once, check the retry in the delivery log, then replay it.
Go-live checklist
- A production endpoint is registered in
https://portal.qasimha.com(UAT endpoints and secrets do not carry over). - The production
whsec_...secret is in your secrets manager, not in code or logs. - Signature verification uses the raw body and rejects failures with
400. - Your handler answers within 15 seconds and processes in the background.
- Duplicate event ids are ignored.
- You re-read the order before you ship or refund.
- Someone checks the delivery log for
Faileddeliveries.
Troubleshooting
Deliveries show HTTP 3xx
Redirects are not followed. Register the final URL, including the right scheme, host and trailing slash.
Deliveries show HTTP 400
Your handler rejected the signature. See Common verification failures. The most common cause is a framework that parsed the JSON before you hashed it.
Deliveries show HTTP 401, 403 or 404
Something in front of your handler (authentication middleware, a firewall, a CSRF check, or a wrong path) refused the request. Exclude the webhook route from login and CSRF checks; the signature is the authentication.
Deliveries show 'Timed out after 15s'
Your handler does the work before it answers. Answer 2xx first and move the work to a queue.
Registration fails with URL_PRIVATE or URL_SCHEME
The URL must be public HTTPS. localhost, private ranges such as 10.x, 192.168.x and 172.16-31.x, and plain http:// are refused. For local testing, use a public HTTPS tunnel.
The same event arrived twice
That is expected after a timeout, a retry or a replay. Ignore an id you have already processed.
No events arrive
Check that the endpoint is active (isActive: true), that events includes the event, that you registered it in the same environment as your orders, and look at the delivery log for errors.
Legacy single-URL format
Some accounts were set up by Qasimha operations with one global webhook URL and no registered endpoint. Those accounts still receive the legacy format:
- Body fields:
eventId,eventType,orderReferenceId,status,amount,currency,occurredAt. There is no Qasimha order id; look the order up withGET /api/v1/merchant/orders/by-reference/{orderReferenceId}or your SDK'sgetByReference. - Events:
order.completed(the order is confirmed),order.declined,order.voided,order.reversed(one per refund,amountis that refund) - Headers:
X-Qasimha-Signature,X-Qasimha-Timestamp,X-Qasimha-Event,X-Qasimha-Event-Id; noX-Qasimha-Delivery-Attempt - Signature:
X-Qasimha-Signature: sha256=<HMAC-SHA256(secret, rawBody)>
This signature doesn't cover a timestamp, so a captured request can be replayed. Registering an active endpoint switches the account to the current format. Do this before you go live.
Related
Updated about 21 hours ago
