Webhooks
Receive signed payment events on your server — set up, verify, deduplicate, and handle retries and ordering.
A webhook is an HTTP POST PayTaka sends to your server when a payment changes. It is the reliable way to learn that a payment was paid — even if the customer closes their browser.
Set up an endpoint#
- Developers → Webhooks → Add endpoint. Choose the environment (Test or Live), enter your HTTPS URL, and pick the events you want (or all of them).
- Copy the signing secret (
whsec_…). It is shown once; rotate it any time from the endpoint page. - Store it as
PAYTAKA_WEBHOOK_SECRETon your server.
Production endpoints must be public https URLs. Use Send test event on a Test endpoint to check your receiver.
Test payments go to Test endpoints; live payments to Live endpoints#
| Payment | Delivered to |
|---|---|
Test payment (livemode: false) | your Test webhook endpoint(s) only |
Live payment (livemode: true) | your Live webhook endpoint(s) only |
Each endpoint has its own signing secret. A very common mistake is registering only a Live endpoint and wondering why test payments send nothing — register one for each environment you use, and use the secret of the endpoint that received the request.
Events#
| Type | Sent when |
|---|---|
payment.created | A payment is created. |
payment.paid | A payment becomes PAID. |
payment.cancelled | A payment is cancelled. |
payment.reversed | A paid payment is reversed in PayTaka's records (not an automatic provider refund). |
endpoint.test | You pressed Send test event (test endpoints only). Contains no Payment. |
Webhooks describe hosted payments, the same objects /v1/payments returns. New event types may be added later: ignore types you don't recognise (the SDK gives you an unknown event you can log and skip).
{
"id": "evt_2q8Zc0mRk1nT",
"object": "event",
"api_version": "v1",
"type": "payment.paid",
"livemode": false,
"created_at": "2026-09-20T12:00:00Z",
"data": { "object": { "id": "…", "status": "PAID", "amount": "500.00" } }
}data.object is exactly what GET /v1/payments/{id} returns — including your external_id, merchant_reference and metadata, so you can match it to your order.
Verify the signature#
Every request carries PayTaka-Signature: t=<unix time>,v1=<hex>. The signature is HMAC-SHA256(secret, "<t>." + rawBody) over the exact bytes received — never a re-serialized JSON. Reject timestamps older than 5 minutes, and compare in constant time.
// Receive PayTaka webhooks with @paytaka/sdk (Node.js 18+, no framework).
import { createServer } from "node:http";
import { PayTakaSignatureVerificationError, verifyWebhook } from "@paytaka/sdk";
// Demo only: PayTaka delivers each event AT LEAST once, so remember the ids you have handled.
// In production use your database (a table with a unique event_id) instead of a Set.
const processed = new Set();
createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/paytaka/webhook") {
res.writeHead(404).end();
return;
}
// 1. Read the RAW body — do not JSON.parse before verifying.
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks);
// 2. Verify the signature. Never trust an unverified webhook.
let event;
try {
event = verifyWebhook(rawBody, req.headers["paytaka-signature"], process.env.PAYTAKA_WEBHOOK_SECRET);
} catch (error) {
if (error instanceof PayTakaSignatureVerificationError) {
res.writeHead(400).end();
return;
}
throw error;
}
// 3. Already handled this event? Acknowledge and stop — never fulfil twice.
if (processed.has(event.id)) {
res.writeHead(200).end();
return;
}
// 4. Fulfil ONLY from a verified payment.paid event. Ignore anything you don't recognise.
if (event.type === "payment.paid") {
console.log("Fulfil the order for payment", event.data.object.id);
}
processed.add(event.id);
// 5. Any 2xx acknowledges the event. Don't rely on delivery order.
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ received: true }));
}).listen(Number(process.env.PORT ?? 3000));The Node.js, Java, Go and Python verifiers are automatically tested against the shared signature vectors. The PHP verifier implements the same algorithm and has not been run in automated tests yet.
Read the raw body#
Read the body before any JSON parsing, or the signature can't match. Framework notes:
- Next.js — use
verifyPayTakaWebhook(request)(it callsrequest.text()), or callrequest.text()yourself. Never callrequest.json()first: the body can only be read once. - Express — mount
express.raw({ type: "application/json" })for the webhook route, before anyexpress.json(). - Laravel — use
$request->getContent(), not$request->all()or->json(), and exclude the route from CSRF verification. - Spring Boot — take the body as
@RequestBody String rawBody; parse it yourself after verifying, rather than binding to a DTO.
Handle events safely#
- Verify the signature (above).
- Deduplicate on
event.id. If you have already processed that id, return200and do nothing. - Fulfil only from a verified
payment.paid— then mark the id processed.
if event already processed → return 200
if event.type == "payment.paid" → fulfil the order for event.data.object
record event.id as processed → return 200Keep processed ids in your database (a unique constraint on event.id makes this race-safe). Delivery is at least once, never exactly once.
Delivery and reliability#
- Respond
2xxquickly to acknowledge. Redirects are not followed;3xx,4xx,5xxand timeouts (10 s) count as failures. - Retries: failed deliveries are retried immediately, then after 30 s, 2 min, 10 min, 30 min, 2 h and 6 h. After that you can resend from the dashboard.
- Duplicates happen. A retry sends the same
idand the same body. - Do not rely on arrival order.
payment.createdmay arrive afterpayment.paid. When your logic is sensitive to order, retrieve the payment (GET /v1/payments/{id}) and act on its current status rather than on the event alone. - An endpoint can be disabled at any time; it then receives nothing new. One that keeps failing is flagged Failing in the dashboard.
Test vectors#
To check your own verification code, webhook-signature-vectors.json in the repository lists deterministic (secret, timestamp, body) → signature cases. All PayTaka SDKs and examples must reproduce them byte for byte.