Gareth Wilson Gareth Wilson

Guide to Yoco Webhooks: Features and Best Practices

Published


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_type field and the IDs of the objects involved.
  • Checkout API. You register a single webhook URL for online payments. Bodies carry a type field and the payment or refund details under payload.

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

FeatureDetails
RegistrationYoco API: POST https://api.yoco.com/v1/webhooks/subscriptions/. Checkout API: POST https://payments.yoco.com/api/webhooks
Event fieldYoco API: event_type. Checkout API: type
Headerswebhook-id, webhook-timestamp, webhook-signature
SchemeBase64 HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}
Secretwhsec_ value, returned only when the subscription or webhook is created
Response window2xx within 15 seconds
RetriesImmediately, then 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h
DeactivationAfter 5 consecutive days with no successful delivery
Replay protectionYoco recommends a timestamp threshold of up to 3 minutes
Source IPs52.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:

AreaEvent types
Item categoriesitem_category.created, item_category.updated, item_category.deleted
Itemsitem.created, item.updated, item.deleted
Ordersorder.created, order.updated, order.completed, order.cancelled
Paymentspayment.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:

  1. Create an HTTPS endpoint that accepts POST requests.
  2. Call POST https://api.yoco.com/v1/webhooks/subscriptions/ with event_types, a name and your notification_url.
  3. Save the secret from the response, for example as YOCO_WEBHOOK_SECRET. Yoco returns it only once. If you lose it, rotate it with the subscription's secret endpoint.

For the Checkout API:

  1. Create an HTTPS endpoint that accepts POST requests. Yoco uses the same endpoint for live and test mode, and the event says which mode it came from.
  2. Call POST https://payments.yoco.com/api/webhooks with a name and your url. Yoco recommends registering only one webhook.
  3. Save the secret from 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.checkoutId with 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.