Skip to main content

Webhook deliveries

When you register a webhook subscription, UGiftMe POSTs JSON to your HTTPS URL whenever a subscribed event occurs. This page documents those inbound deliveries to your server — not the subscription CRUD endpoints.

Delivery contract

ItemDetail
MethodPOST
URLThe url you registered on the subscription
BodyContent-Type: application/json — event-specific object (see below)
SuccessYour endpoint returns HTTP 2xx within 5 seconds (production)
FailureNon-2xx, timeout, or network error triggers retries (see Retries)
Production deliveries use a 5 s timeout. Manual test deliveries (POST /webhooks/test) use 10 s and include X-Webhook-Delivery: test.

Headers

HeaderAlwaysDescription
Content-TypeYesapplication/json
X-Webhook-EventYesEvent name, e.g. order.succeeded
X-Webhook-SignatureYesHMAC-SHA256 hex digest of the JSON body (see Verify signatures)
X-Webhook-DeliveryTest onlytest when sent via POST /webhooks/test
Match X-Webhook-Event to the payload shape you parse. The event name is not repeated inside most order payloads (except webhook.test, which includes type).

Verify signatures

When you create a webhook, the API returns a secret once. Store it securely. UGiftMe signs each delivery as:
HMAC-SHA256(secret, JSON.stringify(payload)) → hex → X-Webhook-Signature
Important: Verification must use the exact JSON bytes UGiftMe sent. Prefer reading the raw request body before parsing JSON. Re-serializing a parsed object can change key order or whitespace and break verification.

Node.js example

import crypto from 'crypto';

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signatureHeader, 'utf8'),
      Buffer.from(expected, 'utf8')
    );
  } catch {
    return false;
  }
}

// Express: use express.raw({ type: 'application/json' }) on your webhook route,
// or middleware that captures rawBody before JSON parsing.
Reject requests with invalid or missing signatures before processing the event.

Retries

Failed deliveries (non-2xx, timeout, or connection error) are retried up to 5 attempts total, with backoff approximately:
AttemptDelay before retry
1 → 21 minute
2 → 35 minutes
3 → 415 minutes
4 → 51 hour
5 → 64 hours
After the final attempt, the delivery is marked failed. deliveryStats on your subscription (visible via GET /webhooks) tracks success and failure counts. Design your handler to be idempotent: the same event may be delivered more than once if a retry occurs after your server already processed the first attempt but returned a non-2xx or timed out.

Order lifecycle (async)

When async order processing is enabled, subscribed order.* events typically fire in sequence:
order.queued → order.processing → order.succeeded | order.failed
Synchronous order creation (201 Created) does not emit order.queued / order.processing / order.succeeded / order.failed. It may still emit wallet.updated when the wallet balance changes. Poll GET /orders/order-requests/{id} if you need full request state in addition to webhooks.

Event payloads

order.queued

Emitted when an async order request is accepted (202). Single order:
{
  "orderRequestId": "65e4f5b6c7d8e90123456789",
  "status": "queued",
  "type": "single",
  "timestamp": "2025-04-11T08:00:00.000Z"
}
Bulk order — includes orderCount:
{
  "orderRequestId": "65e4f5b6c7d8e90123456789",
  "status": "queued",
  "type": "bulk",
  "orderCount": 25,
  "timestamp": "2025-04-11T08:00:00.000Z"
}

order.processing

Worker picked up the order request:
{
  "orderRequestId": "65e4f5b6c7d8e90123456789",
  "status": "processing",
  "type": "single",
  "timestamp": "2025-04-11T08:00:05.000Z"
}
type is "single" or "bulk".

order.succeeded

Fulfillment completed. result shape depends on type: Single — one created order id:
{
  "orderRequestId": "65e4f5b6c7d8e90123456789",
  "status": "succeeded",
  "type": "single",
  "result": {
    "orderId": "65f5a6b7c8d9e01234567890"
  },
  "timestamp": "2025-04-11T08:00:12.000Z"
}
Bulk — summary of created orders:
{
  "orderRequestId": "65e4f5b6c7d8e90123456789",
  "status": "succeeded",
  "type": "bulk",
  "result": {
    "orderIds": ["65f5a6b7c8d9e01234567890", "65f5a6b7c8d9e01234567891"],
    "totalOrders": 2,
    "totalValue": 100,
    "currency": "GBP"
  },
  "timestamp": "2025-04-11T08:00:30.000Z"
}
Use GET /orders/{orderId} for full order details; the webhook carries ids and summary only.

order.failed

Processing failed after retries exhausted:
{
  "orderRequestId": "65e4f5b6c7d8e90123456789",
  "status": "failed",
  "error": "Insufficient available balance. Required: 50, Available: 10 GBP",
  "timestamp": "2025-04-11T08:01:00.000Z"
}

wallet.updated

Balance changed on your business wallet (orders, top-ups, holds, etc.).
{
  "userId": "507f1f77bcf86cd799439011",
  "currency": "GBP",
  "balance": 950.5,
  "availableBalance": 900.5,
  "source": "api_order_async",
  "timestamp": "2025-04-11T08:00:12.000Z",
  "transaction": {
    "type": "debit",
    "amount": 49.5,
    "orderId": "65f5a6b7c8d9e01234567890",
    "details": "Purchase of TESCO-GBP-50 from Tesco"
  }
}
FieldNotes
userIdBusiness user id (same as your integration account)
availableBalancebalance minus active wallet holds
sourcee.g. api_order_sync, api_order_async, or other internal sources
transactionOptional; present when the update is tied to a wallet transaction

product.updated

Full product catalog document as stored internally (same fields you see from the products API). Shape varies by product; treat as a catalog record update notification and refresh via GET /products or search as needed.

webhook.test (test deliveries only)

Not subscribable via events. Sent only through POST /webhooks/test:
{
  "type": "webhook.test",
  "sentAt": "2025-04-11T08:15:00.000Z",
  "data": {
    "message": "This is a test delivery from UGiftMe. Your endpoint should return HTTP 2xx within 10 seconds.",
    "exampleOrderId": "ord_example_123"
  }
}
Header X-Webhook-Event is webhook.test. Header X-Webhook-Delivery is test.

Test your endpoint

Use the Business API (with X-API-Key) after registering a webhook:
MethodPathPurpose
GET/api/v1/business/webhooks/test-sampleReturns { event, payload } sample for webhook.test (fresh sentAt when actually sent)
POST/api/v1/business/webhooks/testBody { "webhookId": "<id>" } — POSTs a signed test payload to that subscription’s URL
POST /test response:
{
  "ok": true,
  "statusCode": 200,
  "payload": { "type": "webhook.test", "sentAt": "...", "data": { ... } }
}
On failure, ok is false and errorMessage describes the problem (e.g. non-2xx from your URL).