Get told when an order is paid, fails or is refunded. Verify the signature, acknowledge fast, and handle repeats.
Webhooks tell your server when an order’s payment outcome changes, so you
don’t have to poll. They fire for every order in your organization, not
only the ones your integration created; use attribution to tell them apart.
Webhook endpoints are managed in the Laces app by an owner or admin. There are
no webhook-management routes on the Partner API.
1
Open Webhooks
Settings → Developers (the tile at the bottom of Settings), then the
Webhooks section (“Get told when an order is paid, fails or is
refunded”).
Developers, with the Webhooks section
2
Add an endpoint
Tap Add endpoint. Enter the Endpoint URL, a public https address
such as https://shop.example.com/laces/webhook, and choose the
Events to send.
The Add endpoint form
3
Copy the signing secret
The Your signing secret sheet shows the secret (LCSWHSEC-…-X)
once, with a copy button and Copy all (an .env block with
LACES_WEBHOOK_ENDPOINT_ID and LACES_WEBHOOK_SIGNING_SECRET). Store it
on your server, next to your client secret.
The signing secret is shown once (secret value blurred here)
Your endpoint then appears in the list.
A webhook endpoint in the list
4
Send a test event
Open the endpoint and tap Send test event. Laces posts a signed ping
to your URL and shows the HTTP status your server returned. Aim for a 2xx.The example below was sent to a placeholder address that does not accept
webhooks, so the app reports the status it got back (HTTP 405) instead of
pretending it worked.
Send test event reporting the status your endpoint returned
Open an endpoint to see its last delivery status. You can edit its URL and
events at any time, and from Endpoint actions:
Roll signing secret: issues a new secret, shown once, and the old one
stops signing immediately (there is no overlap). Deliveries still queued
are signed with the new secret at their next attempt, so be ready to accept
the new secret before you roll, or deliveries fail until you deploy it.
Disable endpoint / Re-enable: a disabled endpoint receives nothing.
Re-enabling resets the failure counter.
Delete endpoint: permanent; Laces stops sending to it.
An organization can have up to 5 webhook endpoints.URL rules.https only; no credentials in the URL; no localhost,
internal or single-label host names; the name must resolve to public
addresses only. Private, loopback, link-local and cloud-metadata addresses are
refused, both when you save the URL and again at send time. Redirects are not
followed, so register the final URL.Unlike allowed domains, a webhook URL can never be http or localhost. To
receive webhooks on your laptop, see Test your endpoint.
A payment attempt failed or expired. data.order.failure_kind says why. A failure where the charge never reached the payment provider is not sent (nothing was charged, and the buyer can simply try again).
order.refunded
A full or partial refund completed. data.order.status is refunded or partially_refunded, and data.refund is included. Each refund on an order sends its own event.
ping
Only from Send test event
Every path that marks an order paid fires order.paid: mobile money
settlements (and card, once it is available), however Laces learns of them,
and free orders confirmed without payment.
Unique per event. Equal to the X-Laces-Delivery header. Constant across retries.
type
The event type
created
Unix seconds when the event was queued. Does not change across retries.
event_id
The Laces event (race) the order belongs to
attribution
How the order came in. channel is partner_api (custom UI), partner_hosted (hosted or embedded checkout) or null (not through the Partner API). partner_reference is the value you sent on order creation.
data.order
The order, without buyer details
data.order.failure_kind
On order.failed: why the attempt failed, one of provider_not_reached, timed_out, declined or other (treat unknown values as other). null on other events.
For order.failed, use failure_kind to choose what to tell the buyer:
failure_kind
Meaning
Suggested message
timed_out
The prompt wasn’t approved in time, or expired
”The payment wasn’t approved in time. Please try again.”
declined
The buyer declined or cancelled, or can’t pay (balance, PIN, limit)
“The payment was declined. Check your balance or try another number.”
provider_not_reached
The charge never reached the provider; nothing was charged, safe to retry. Normally not sent as a webhook, but handle it.
”We couldn’t reach the payment service. You haven’t been charged. Please try again.”
other
Anything else
”The payment didn’t go through. Please try again.”
For order.refunded, data also has a refund object:
{ "id": "LCSWHD-PING-EXAMPLE", "type": "ping", "created": 1791533643, "data": { "message": "This is a test event from Laces." }}
Webhooks never contain buyer personal data: no name, email or phone.
The webhook is a signal to act, plus enough to match your own record through
attribution.partner_reference or attribution.checkout_session_id. If you
need buyer details, read the order with
GET /orders/{orderId}, which needs
orders:read.
Reject if t is more than 300 seconds from your clock (replay
protection).
Compute the HMAC over `${t}.${rawBody}` using the whole secret,
including the LCSWHSEC- prefix and the -X suffix, as the key.
Compare to each v1 in constant time. Accept if any matches.
Verify against the raw bytes of the body, before any JSON parsing.
Parsing and re-serialising changes whitespace and key order and breaks the
signature. In Express that means express.raw() on this route, not
express.json().
const crypto = require("crypto");const express = require("express");function verifyLacesSignature(rawBody, header, secret, toleranceSec = 300) { let t = null; const sigs = []; for (const part of String(header || "").split(",")) { const i = part.indexOf("="); if (i < 0) continue; const k = part.slice(0, i).trim(); const v = part.slice(i + 1).trim(); if (k === "t" && /^\d{1,12}$/.test(v)) t = Number(v); if (k === "v1") sigs.push(v); } if (t === null || sigs.length === 0) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`, "utf8") .digest("hex"); const exp = Buffer.from(expected, "utf8"); return sigs.some((s) => { const got = Buffer.from(s, "utf8"); return got.length === exp.length && crypto.timingSafeEqual(got, exp); });}const app = express();// express.raw keeps the body as a Buffer. Do NOT use express.json() here.app.post("/laces/webhook", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); const ok = verifyLacesSignature( raw, req.header("X-Laces-Signature"), process.env.LACES_WEBHOOK_SECRET, ); if (!ok) return res.status(400).send("bad signature"); const event = JSON.parse(raw); res.status(200).send("ok"); // acknowledge fast... handleEventOnce(req.header("X-Laces-Delivery"), event) // ...then do the work .catch((err) => logger.error("laces webhook", err));});
import hmac, hashlib, json, os, timefrom flask import Flask, requestdef verify_laces_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: t, sigs = None, [] for part in (header or "").split(","): k, _, v = part.partition("=") k, v = k.strip(), v.strip() if k == "t" and v.isdigit() and len(v) <= 12: t = int(v) elif k == "v1": sigs.append(v) if t is None or not sigs: return False if abs(time.time() - t) > tolerance: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return any(hmac.compare_digest(s, expected) for s in sigs)app = Flask(__name__)@app.post("/laces/webhook")def laces_webhook(): raw = request.get_data() # raw bytes, read BEFORE request.json if not verify_laces_signature(raw, request.headers.get("X-Laces-Signature", ""), os.environ["LACES_WEBHOOK_SECRET"]): return "bad signature", 400 event = json.loads(raw) enqueue_once(request.headers["X-Laces-Delivery"], event) # do slow work async return "ok", 200
<?phpfunction verify_laces_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $t = null; $sigs = []; foreach (explode(',', $header) as $part) { $pos = strpos($part, '='); if ($pos === false) continue; $k = trim(substr($part, 0, $pos)); $v = trim(substr($part, $pos + 1)); if ($k === 't' && ctype_digit($v) && strlen($v) <= 12) $t = (int) $v; if ($k === 'v1') $sigs[] = $v; } if ($t === null || !$sigs) return false; if (abs(time() - $t) > $tolerance) return false; $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $s) { if (hash_equals($expected, $s)) return true; } return false;}$raw = file_get_contents('php://input'); // the raw body, not $_POST$ok = verify_laces_signature($raw, $_SERVER['HTTP_X_LACES_SIGNATURE'] ?? '', getenv('LACES_WEBHOOK_SECRET'));if (!$ok) { http_response_code(400); exit('bad signature'); }$event = json_decode($raw, true);// Record $_SERVER['HTTP_X_LACES_DELIVERY'] and skip it if already processed.http_response_code(200);echo 'ok';
Acknowledge with any 2xx within 10 seconds. Anything else, a timeout,
or a network error is a failed attempt. The response body is ignored.
Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes,
2 hours, 6 hours and 12 hours: 7 attempts in total over about
20.5 hours. Retries are sent by a job that runs every 5 minutes, so one can
arrive up to 5 minutes after its nominal time.
Network errors, timeouts, 5xx, 408 and 429 are retried through the
whole schedule. A 3xx (redirects are never followed) or any other 4xx is
treated as permanent after 3 attempts.
After the last failed attempt the delivery is marked permanently failed and
not retried again. Reconcile by reading orders or sessions.
Auto-disable: after 50 consecutive failed attempts on an endpoint,
Laces switches it off, and deliveries still queued for it are dropped. Fix
your receiver, then Re-enable it in the app (the counter resets). A
successful delivery also resets the counter.
Payments are real (there is one environment), so test in
two steps:
Signature and plumbing: Send test event. In the app, open the endpoint
and tap Send test event. Laces posts a signed ping to your URL right
away and shows the result: “Test delivered (HTTP 200)”, or the status your
server answered, or that it could not reach your endpoint. A ping is sent
once and never retried, and it doesn’t count towards auto-disable. Use it
to prove your signature check and your 2xx response.
Real events: a test order. Buy a free or low-price ticket on an
unlisted test event to receive order.paid, then refund it in the app to
receive order.refunded.
To receive webhooks on your laptop, run a public https tunnel (for example
ngrok or Cloudflare Tunnel) to your local server and register the tunnel’s
https URL as the endpoint. Remember to point the endpoint back at your real
server (or delete it) when you’re done.
The same event can arrive more than once. Store each X-Laces-Delivery id
when you process it, ignore repeats, and make the handler safe to run twice.
Events can arrive out of order, for example order.refunded before a
retried order.paid. Always act on data.order.status, never on the order
in which events arrived.
An order produces at most one order.paid, one order.failed, and one
order.refunded per refund, per endpoint. If an order fails twice and then
succeeds, you see a single order.failed and then order.paid.
async function handleEventOnce(deliveryId, event) { // A unique index on delivery_id makes this race-safe across instances. const fresh = await db.webhookDeliveries.insertIfAbsent({ delivery_id: deliveryId }); if (!fresh) return; const order = event.data?.order; if (!order) return; // ping if (order.status === "paid") await fulfil(order.attribution?.partner_reference, order); else if (order.status === "refunded" || order.status === "partially_refunded") await markRefunded(order); else if (order.status === "failed") await markFailed(order);}