Skip to main content
Instead of polling List escrow transactions, let Payluk push escrow updates to you. Whenever an escrow you manage is created or changes status, Payluk sends a signed POST request to your registered callback URL.

Prerequisites

A webhook is sent only when all of these hold:
You’ve set a callback URL for the environment (test or live) in your dashboard business settings.
The escrow carries your merchantId — i.e. it was created through the API under your merchant account.
The escrow’s seller is one of your merchant customers (merchant_user).
If any is missing, no webhook fires and the underlying escrow operation still succeeds as normal.
Payluk picks the callback URL and signing key by the escrow’s environment: escrows created with a sk_live_ key post to your live callback URL and are signed with your live secret; sk_test_ escrows post to your test callback URL signed with your test secret.

Events

The event field is escrow.<status> (lower-cased), plus escrow.created when a new escrow is generated. Every status transition emits one event:
EventFires whenLifecycle
escrow.createdA new escrow link is generatedAWAITING_PAYMENT
escrow.pendingEscrow is created/awaiting fundingAWAITING_PAYMENT
escrow.ongoingBuyer funded the escrow; funds heldOPENED
escrow.completedBuyer confirmed delivery / final milestone releasedCLOSED
escrow.claimedSeller claimed funds after the delivery windowCLOSED
escrow.disputedA party opened a disputeOPENED
escrow.investigatingDispute is under reviewOPENED
escrow.refundedDispute resolved in the buyer’s favourCLOSED
See Escrow lifecycle for how these map to state and status.
Multi-quantity links emit events for each escrow independently — the original link and every cloned escrow. Match on data.id, not the paymentToken. See Multi-quantity escrows.

The payload

Every webhook body has the same envelope:
{
  "event": "escrow.completed",
  "data": {
    "id": "665f1b2c9a1e4d0012ab3c10",
    "amount": 150000,
    "purpose": "MacBook Pro 14\"",
    "description": "Space grey, sealed",
    "whoPays": "both",
    "imageUrl": ["https://cdn.payluk.ng/escrow/abc.png"],
    "fee": 3750,
    "paymentToken": "PY_8AB12C9D3045",
    "sellerId": "665f1b2c9a1e4d0012ab3c01",
    "buyerId": "665f1b2c9a1e4d0012ab3c02",
    "paidAt": "1750593600",
    "status": "COMPLETED",
    "state": "CLOSED",
    "logs": [],
    "channel": "API",
    "dispute": null,
    "category": null,
    "completedAt": "2026-06-22T12:00:00.000Z",
    "approvedClaimBy": null,
    "refundedBy": null,
    "paymentId": "665f1b2c9a1e4d0012ab3c99",
    "maxDelivery": 3,
    "deliveryDetails": null,
    "deliveryTimeline": "days",
    "totalQuantity": 1,
    "settlementType": "STANDARD",
    "milestones": null,
    "createdAt": "2026-06-22T10:15:00.000Z",
    "updatedAt": "2026-06-22T12:00:00.000Z",
    "environment": "live",
    "merchantId": "665f1b2c9a1e4d0012ab3c00"
  },
  "timestamp": "2026-06-22T12:00:00.000Z"
}
FieldDescription
eventThe event name, e.g. escrow.completed.
dataThe full escrow object (same shape across all events).
timestampISO-8601 time the webhook was generated.
The data object mirrors the escrow you get from the API — including status, state, settlementType, milestones, totalQuantity, environment and merchantId.

Verify the signature

Each request carries an x-payluk-signature header: an HMAC-SHA512 of the raw JSON body, keyed with your environment’s secret key. Always verify it before trusting a payload.
Headers
Content-Type: application/json
User-Agent: Payluk-Webhook/1.0
x-payluk-signature: 9f86d081…   # hex HMAC-SHA512 of the body
import crypto from "crypto";
import express from "express";

const app = express();

// Capture the raw body so the signature matches byte-for-byte.
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));

app.post("/webhooks/payluk", (req, res) => {
  const secret = process.env.PAYLUK_SECRET_KEY; // sk_live_… or sk_test_…
  const expected = crypto
    .createHmac("sha512", secret)
    .update(req.rawBody)
    .digest("hex");

  const received = req.headers["x-payluk-signature"];

  const valid =
    received &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));

  if (!valid) return res.status(401).send("Invalid signature");

  const { event, data } = req.body;
  // …update your records based on event / data.status …

  res.sendStatus(200); // acknowledge quickly
});
Sign against the exact raw bytes of the request body. If you re-serialize the parsed JSON, key ordering or spacing may differ and the signature won’t match.

Delivery & retries

BehaviourDetail
MethodPOST with a JSON body.
HeadersContent-Type: application/json, User-Agent: Payluk-Webhook/1.0, x-payluk-signature.
TimeoutPayluk waits up to 10 seconds for your response.
RetriesFailed attempts are retried up to 3 times with exponential backoff.
DeliveryBest-effort — webhook failures are logged but never block or roll back the escrow operation.
Acknowledge fast with a 2xx, then do heavy work asynchronously. If you need state that may have arrived out of order, re-fetch the escrow with Verify payment token using the paymentToken in the payload.

Best practices

Treat any request with a missing or mismatched x-payluk-signature as untrusted and reject it with 401. Use a constant-time comparison.
The same event may be delivered more than once (a retry after a slow 2xx). De-duplicate on data.id + event (or data.status) so reprocessing is a no-op.
Events are sent as state changes happen and may arrive out of order under retries. Trust data.status / data.state as the source of truth rather than the sequence of events.
Configure distinct callback URLs and verify each with its matching secret (sk_test_ vs sk_live_). The data.environment field tells you which one a payload belongs to.