# 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_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

| 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:

```json
{
  "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.

```javascript
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:

```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](/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) verifies Yoco's signatures, responds on time, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Yoco webhooks reliably in minutes.