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.

Important

Webhooks 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 needDetails
A public HTTPS URLReachable from the internet, with a valid certificate. Private, local and link-local addresses are refused.
A secret API keysk_test_... (UAT) or sk_live_... (production) with the webhooks:manage scope, if you register endpoints with the API. See Authentication.
Raw-body accessYour framework must give you the request body exactly as received, so you can verify the signature.
A store for processed eventsA table or cache of event ids you have handled, to remove duplicates.
EnvironmentAPI hostMerchant Portal
UAThttps://api-uat.qasimha.comhttps://portal-uat.qasimha.com
Productionhttps://api.qasimha.comhttps://portal.qasimha.com

Register an endpoint

Register in the Merchant Portal → Settings → Webhooks, or with the API (scope webhooks:manage):

CallPurpose
POST /api/v1/merchant/webhooksRegister an endpoint; returns its signing secret once
GET /api/v1/merchant/webhooksList 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-secretIssue a new signing secret
POST /api/v1/merchant/webhooks/{id}/pingSend a signed ping test event
GET /api/v1/merchant/webhooks/{id}/deliveriesDelivery log, with every attempt
POST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replaySend 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 the webhooks:manage scope 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"]
  }'
FieldTypeRequiredNotes
urlstringYesPublic https:// URL, up to 1024 characters.
descriptionstringNoUp to 200 characters.
eventsstring[]NoEvent 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.

FieldTypeMeaning
idstring (UUID)Endpoint id
urlstringWhere deliveries go
descriptionstring or nullYour label
eventsstring[]Subscribed events. [] means every event.
isActivebooleanfalse pauses the endpoint: new events are not sent to it
secretHintstringLast 4 characters of the current secret, so you can tell which secret a server holds
previousSecretExpiresAtstring or nullAfter a rotation, when the old secret stops signing deliveries
createdAtstring (UTC)When the endpoint was registered
updatedAtstring or nullLast change. Not present in the create and rotate responses.
secretstringCreate 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"
QueryDefaultNotes
statusallPending, Succeeded or Failed
page11 or greater
pageSize20Up 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:

FieldMeaning
idDelivery id. Use it to replay.
eventIdThe event as a UUID. The evt_... id in the body and headers is evt_ + this UUID without dashes.
eventTypeFor example order.confirmed or ping
orderIdThe Qasimha order id; null for ping
orderReferenceIdYour order reference; null for ping
statusPending (waiting for its next attempt), Succeeded (your server answered 2xx) or Failed (every attempt failed, or the endpoint was deleted)
attemptsAttempts made so far
maxAttemptsAttempts allowed for this delivery
nextAttemptAtWhen the next retry is due; null when none is left
lastAttemptAtWhen the last attempt finished
lastStatusCodeHTTP status your server returned; null on a timeout or connection error
lastErrorFor example HTTP 500, Timed out after 15s, or the connection error
responseSnippetThe first 1000 characters of your server's response body
durationMsHow long the last attempt took
succeededAtWhen your server accepted it
replayOfIdSet on a replay: the delivery it repeats
createdAtWhen 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:

OperationNode.js (qasimha.webhookEndpoints)PHP ($qasimha->webhookEndpoints()).NET (client.WebhookEndpoints)Java (client.webhookEndpoints())
RegistercreatecreateCreateAsynccreate
ListlistlistListAsynclist
Read onegetgetGetAsyncget
UpdateupdateupdateUpdateAsyncupdate
DeletedeletedeleteDeleteAsyncdelete
Rotate secretrotateSecretrotateSecretRotateSecretAsyncrotateSecret
Test eventpingpingPingAsyncping
Delivery loglistDeliverieslistDeliveriesListDeliveriesAsynclistDeliveries
ReplayreplayDeliveryreplayDeliveryReplayDeliveryAsyncreplayDelivery
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

StatusCodeCause
400URL_REQUIREDurl is missing or empty
400URL_INVALIDNot an absolute URL with a valid host
400URL_SCHEMENot https://
400URL_PRIVATEThe host is a private, local or link-local address
400URL_TOO_LONGLonger than 1024 characters
400URL_USERINFOThe URL contains a user name or password
400URL_FRAGMENTThe URL contains a #fragment
400INVALID_EVENTAn entry in events is not a known event type
400LIMIT_EXCEEDEDThe account already has 5 endpoints
400VALIDATION_ERRORdescription is longer than 200 characters, or page / pageSize is out of range
400INVALID_STATUSstatus filter is not Pending, Succeeded or Failed
401—Missing, invalid or revoked API key
403FORBIDDENThe key lacks webhooks:manage, or the portal user is not an owner or admin
404NOT_FOUNDNo such endpoint or delivery on your account

Events

EventWhenamount in the bodyUsual status
order.confirmedCheckout finished: the customer paid the first instalment and signed the contractOrder totalConfirmed
order.declinedDeclined, or the customer abandoned it on the confirmation callOrder totalDeclined
order.fulfilledYou recorded a shipment and the whole order is now shippedThat shipmentFullyFulfilled
order.partially_fulfilledYou recorded a shipment and part of the order is still unshippedThat shipmentPartiallyFulfilled
order.refundedA refund was recorded and the whole order is now refundedThat refundFullyReversed
order.partially_refundedA refund was recorded and part of the order is still not refundedThat refundPartiallyReversed
order.voidedThe order was voidedWhat was releasedVoided
order.cancelledYou cancelled an open checkoutOrder totalCancelled
order.expiredThe customer never completed checkoutOrder totalExpired
checkout_link.expiredThe same, for an order created from a payment link or QR codeOrder totalExpired
pingTest event from the portal or the ping endpointNot in the bodyNot 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. ping is always sent to the endpoint it targets, whatever events says.

order.completed is being retired

order.completed is accepted as an alias for order.confirmed for one release only. Subscribe to order.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"
    }
  }
}
FieldTypeMeaning
idstringEvent id, evt_ + 32 hex characters. The same on every retry and replay. Remove duplicates with it.
typestringEvent type, as in the Events table
createdAtstring (UTC)When the event happened. It does not change between retries.
apiVersionstringPayload version, v1
orderIdstring (UUID)The Qasimha order id
orderReferenceIdstringYour order reference
statusstringOrder status after the change
amountnumberThe amount this event is about. See the Events table.
currencystringISO 4217 code, SAR
data.order.idstring (UUID)Same as orderId
data.order.orderReferenceIdstringSame as orderReferenceId
data.order.statusstringSame as status
data.order.channelstringOnline, InStore or QrCode
data.order.checkoutChannelstring or nullHow a hosted checkout started, for example Link or Qr for a payment link or QR code
data.order.totalAmountnumberOrder total
data.order.currencyCodestringSAR
data.order.capturedAmountnumberTotal shipped (fulfilled) so far
data.order.refundedAmountnumberTotal refunded so far
data.order.voidedAmountnumberTotal voided
data.order.instalmentsnumberNumber of instalments
data.order.createdAtstring (UTC)When the order was created
data.order.confirmedAtstring or nullWhen 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

HeaderExamplePurpose
X-Qasimha-Signaturet=1758790800,v1=1eb16bb1…Signature. See Verifying webhook signatures. Two v1 values for 24 hours after a secret rotation.
X-Qasimha-Timestamp1758790800Unix seconds when this attempt was sent; same as t
X-Qasimha-Eventorder.confirmedEvent type
X-Qasimha-Event-Idevt_9f8c…Event id. The same on every retry, so use it to remove duplicates.
X-Qasimha-Delivery-Attempt11 on the first attempt, then 2 to 6. Starts at 1 again on a replay.
Content-Typeapplication/json; charset=utf-8Body format
User-AgentQasimha-Webhooks/1.0Identifies 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 2xx status within 15 seconds. Do the real work in the background.
  • Only 2xx counts 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:
AttemptSent
1Right away
21 minute after attempt 1 failed
35 minutes after attempt 2 failed
430 minutes after attempt 3 failed
52 hours after attempt 4 failed
621 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

  1. Verify the signature first. Reject anything that fails with 400.
  2. Respond 2xx right away, then process the event in the background.
  3. Remove duplicates using the event id (or X-Qasimha-Event-Id). Retries and replays repeat it.
  4. Events can arrive out of order. Don't assume the sequence.
  5. 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.
  6. Answer 2xx to 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

  1. Create a UAT secret key with webhooks:manage in https://portal-uat.qasimha.com.
  2. Register your endpoint against https://api-uat.qasimha.com. Use a public HTTPS tunnel if your server runs on your machine.
  3. Call POST /api/v1/merchant/webhooks/{id}/ping and check that your server returns 200 and the delivery shows Succeeded.
  4. Run a test checkout and watch order.confirmed arrive. Then fulfil, refund or cancel it and watch the matching events.
  5. Return 500 from 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 Failed deliveries.

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 with GET /api/v1/merchant/orders/by-reference/{orderReferenceId} or your SDK's getByReference.
  • Events: order.completed (the order is confirmed), order.declined, order.voided, order.reversed (one per refund, amount is that refund)
  • Headers: X-Qasimha-Signature, X-Qasimha-Timestamp, X-Qasimha-Event, X-Qasimha-Event-Id; no X-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


Did this page help you?