Guide to Yoco Webhooks: Features and Best Practices
Yoco webhooks tell your application when something happens in a Yoco account: a payment is taken or refunded, an order changes, or a product in the catalogue is edited. If you take payments with Yoco, whether through its point-of-sale API or its online Checkout, webhooks are how your system finds out that money has moved without polling.
This guide covers both families of Yoco webhooks, how they are signed, Yoco's retry and deactivation rules, and the best practices for running a Yoco receiver in production.
What are Yoco webhooks?
Yoco has two APIs that send webhooks, and they differ in how you register and what the body looks like:
- Yoco API. You create a webhook subscription that lists the event types you want and the URL to notify. Bodies are flat JSON objects with an
event_typefield and the IDs of the objects involved. - Checkout API. You register a single webhook URL for online payments. Bodies carry a
typefield and the payment or refund details underpayload.
Both send a JSON POST with the same three headers, webhook-id, webhook-timestamp and webhook-signature, and both use the Standard Webhooks signature scheme. Yoco's docs point you to the Standard Webhooks libraries for verification.
Yoco webhook features
| Feature | Details |
|---|---|
| Registration | Yoco API: POST https://api.yoco.com/v1/webhooks/subscriptions/. Checkout API: POST https://payments.yoco.com/api/webhooks |
| Event field | Yoco API: event_type. Checkout API: type |
| Headers | webhook-id, webhook-timestamp, webhook-signature |
| Scheme | Base64 HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body} |
| Secret | whsec_ value, returned only when the subscription or webhook is created |
| Response window | 2xx within 15 seconds |
| Retries | Immediately, then 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h |
| Deactivation | After 5 consecutive days with no successful delivery |
| Replay protection | Yoco recommends a timestamp threshold of up to 3 minutes |
| Source IPs | 52.215.16.239, 54.216.8.72, 63.33.109.123 (listed in the Yoco API docs) |
Common events
The Yoco API's event types cover the catalogue, orders and payments:
| Area | Event types |
|---|---|
| Item categories | item_category.created, item_category.updated, item_category.deleted |
| Items | item.created, item.updated, item.deleted |
| Orders | order.created, order.updated, order.completed, order.cancelled |
| Payments | payment.created, payment.refunded |
A Yoco API event is small. This is the payment.created example from Yoco's docs:
{
"business_id": "f3f777bb-ca3c-4638-bc79-8fcf0fd0f8f1",
"event_type": "payment.created",
"order_id": "e13ed7f6-56a5-42f4-be6b-f87063ffd8f3",
"payment_id": "d2ff5a9a-0998-46e2-aebe-2c72c3ddde1f"
}
The event gives you IDs, so fetch the payment or order from the API when you need its details.
The Checkout API has four event types: payment.succeeded, payment.failed, refund.succeeded and refund.failed. Each one arrives as {id, type, createdDate, payload}, with the checkout's ID in payload.metadata.checkoutId.
Setting up Yoco webhooks
For the Yoco API:
- Create an HTTPS endpoint that accepts
POSTrequests. - Call
POST https://api.yoco.com/v1/webhooks/subscriptions/withevent_types, anameand yournotification_url. - Save the
secretfrom the response, for example asYOCO_WEBHOOK_SECRET. Yoco returns it only once. If you lose it, rotate it with the subscription's secret endpoint.
For the Checkout API:
- Create an HTTPS endpoint that accepts
POSTrequests. Yoco uses the same endpoint for live and test mode, and the event says which mode it came from. - Call
POST https://payments.yoco.com/api/webhookswith anameand yoururl. Yoco recommends registering only one webhook. - Save the
secretfrom the response. It is also returned only once.
If your endpoint is behind a firewall or NAT, allow 52.215.16.239, 54.216.8.72 and 63.33.109.123. Yoco's webhook docs for the Yoco API list these as the source of all deliveries.
Securing Yoco webhooks
Concatenate webhook-id, webhook-timestamp and the raw body with ., compute an HMAC-SHA256 using the base64-decoded part of the secret after whsec_, base64-encode the result, and compare it with each v1, entry in webhook-signature. The header can hold several space-separated signatures, and a match on any of them is enough.
const crypto = require('crypto');
const express = require('express');
function verifyYocoSignature(rawBody, headers, secret) {
const msg_id = headers['webhook-id'];
const msg_timestamp = headers['webhook-timestamp'];
const signature_header = headers['webhook-signature'];
if (!msg_id || !msg_timestamp || !signature_header) return false;
if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;
// Optional replay check. Yoco recommends a threshold of up to 3 minutes.
// const age_seconds = Math.abs(Date.now() / 1000 - Number(msg_timestamp));
// if (!Number.isFinite(age_seconds) || age_seconds > 180) return false;
// The HMAC key is the base64-decoded part of the secret after "whsec_".
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const signed_content = Buffer.concat([Buffer.from(`${msg_id}.${msg_timestamp}.`, 'utf8'), rawBody]);
const expected = Buffer.from(
crypto.createHmac('sha256', key).update(signed_content).digest('base64'),
'utf8'
);
return signature_header.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/yoco', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.YOCO_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyYocoSignature(req.body, req.headers, secret)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
const event_type = event.event_type || event.type; // Yoco API or Checkout API
// Deduplicate on the webhook-id header, queue the event, then respond.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
import os
def verify_yoco_signature(raw_body: bytes, headers, secret: str) -> bool:
msg_id = headers.get("webhook-id")
msg_timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not (msg_id and msg_timestamp and signature_header):
return False
if not secret or not secret.startswith("whsec_"):
return False
key = base64.b64decode(secret[len("whsec_"):])
signed_content = f"{msg_id}.{msg_timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
return True
return False
# In FastAPI: raw_body = await request.body()
# verify_yoco_signature(raw_body, request.headers, os.environ["YOCO_WEBHOOK_SECRET"])
The standardwebhooks packages for Node.js, Python and PHP do the same work and also reject old timestamps. Yoco signs each retry with a new timestamp, so a timestamp check doesn't break retries.
Make Yoco webhooks production-ready. Hookdeck Event Gateway verifies Yoco's
webhook-signature, answers inside Yoco's 15-second window, and queues every payment and refund event until your service has handled it.
Hookdeck's Yoco source type verifies the webhook-* headers with the whsec_ secret you enter. Verification is optional on the source, so leave it off if you didn't set a secret. Hookdeck does not apply a timestamp window.
Yoco webhook limitations and pain points
Checkout webhooks receive every payment
The Problem: Your Checkout handler marks the wrong order as paid, or processes payments that have nothing to do with it.
Why It Happens: A Checkout API webhook receives all payment events processed for the account, not only the ones for checkouts your code created. Yoco asks you to filter on the checkout ID.
Workarounds:
- Compare
payload.metadata.checkoutIdwith the checkout ID you stored when you created the checkout, and ignore events that don't match. - Register one webhook, as Yoco recommends, and route from there.
How Hookdeck Can Help: Hookdeck filters can match on body fields, so you can route Checkout events to different services by type or drop events your service doesn't handle.
A 15-second response window
The Problem: Slow handlers cause retries and duplicate deliveries.
Why It Happens: Yoco treats a response that takes longer than 15 seconds as a failure and moves on to the next retry.
Workarounds:
- Verify, store the event, return a
2xx, and do the work in a background job.
How Hookdeck Can Help: Hookdeck accepts the request straight away and delivers it to your service with its own retry policy, so your processing time no longer counts against Yoco's window.
Retries stop after about 28 hours, and subscriptions switch off after 5 days
The Problem: A long outage loses events, and a longer one stops deliveries altogether.
Why It Happens: Yoco makes eight attempts over roughly 27 and a half hours and then drops the event. If nothing has been delivered for 5 consecutive days, the subscription is deactivated. For the Yoco API you re-enable it with the update subscription endpoint. For the Checkout API the webhook is turned off and you must register a new one.
Workarounds:
- Alert on failed deliveries, and after an outage, reconcile payments through the API.
- Check the subscription's state after any long downtime.
How Hookdeck Can Help: Hookdeck keeps accepting Yoco's deliveries while your service is down, so Yoco sees successful deliveries and the subscription stays active. The events wait in Hookdeck until you replay them.
A secret you see only once
The Problem: After a redeploy or a lost environment variable, you can't verify anything.
Why It Happens: Yoco returns the whsec_ secret only in the response that creates the subscription or webhook.
Workarounds:
- Write the secret to your secrets manager as part of the code that creates the subscription.
- For the Yoco API, rotate the secret if it is lost.
How Hookdeck Can Help: With Hookdeck verifying signatures, the secret lives in one place, the Hookdeck source, rather than in every service that handles Yoco events.
Best practices
Verify against the raw body
Yoco warns that parsing and re-serialising JSON changes the signature. Use express.raw() in Express, Buffer.from(await request.arrayBuffer()) in a Next.js route handler, or await request.body() in FastAPI, and parse only after verification.
Deduplicate on webhook-id
Yoco says events may be delivered more than once and recommends storing the webhook-id values you have processed and skipping repeats. See our guide to webhook idempotency.
Route on the event field for each API
Read event_type for Yoco API events and type for Checkout API events. If one endpoint receives both, check for both fields.
Fetch details when you need them
Yoco API events carry IDs only. Look up the payment or order before acting on amounts or line items.
Acknowledge fast and process asynchronously
Return a 2xx as soon as the event is stored. See why to process webhooks asynchronously.
Conclusion
Yoco's two APIs register webhooks differently and shape their bodies differently, but both sign with the Standard Webhooks scheme and both follow the same retry schedule. Verify each request against the raw body with your whsec_ secret, deduplicate on webhook-id, filter Checkout events by checkoutId, and answer within 15 seconds.
Hookdeck Event Gateway verifies Yoco's signatures, responds on time, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Yoco webhooks reliably in minutes.