# Guide to Yapily Webhooks: Features and Best Practices

Yapily webhooks tell your application when something changes on the Yapily platform: a payment you initiated reaches `COMPLETED` or `FAILED`, a hosted payment finishes, or an asynchronous Data Plus categorisation is ready to collect. Yapily is an open-banking API platform, and without webhooks you would poll payment and data endpoints and spend your rate limit doing it.

This guide covers how Yapily webhooks are registered and signed, how secret rotation works, the events you can subscribe to and their envelope, and the best practices for running a Yapily receiver in production. Yapily webhooks are in private beta, so ask your Customer Success Manager for access first.

## What are Yapily webhooks?

A Yapily webhook is a subscription to one or more event categories, registered through the API with a callback URL. When an event in one of those categories fires, Yapily sends an HTTPS `POST` with a JSON body to the URL.

Every request carries a `webhook-signature` header: the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook's secret. While you rotate the secret with a delay, Yapily also sends `webhook-rotation-signature`, signed with the other secret.

Despite the header name, this is not Standard Webhooks. There is no `webhook-id` or `webhook-timestamp`, the value has no `v1,` prefix, and only the body is signed. A Standard Webhooks library will reject every Yapily request.

## Yapily webhook features

| Feature | Details |
| --- | --- |
| Availability | Private beta |
| Configuration | API only: `GET /webhook/events/categories`, then `POST /webhook/events` |
| Subscription model | One or more event categories per webhook, up to 10 webhooks per application |
| Signature header | `webhook-signature`, plus `webhook-rotation-signature` during a delayed secret reset |
| Scheme | HMAC-SHA256, hex digest, no prefix |
| Signed content | The raw request body only |
| Secret | One per webhook, shown only in the registration response. Reset with `POST /webhook/secrets/{webhook_id}` |
| Replay window | None. No timestamp is sent or signed |
| Response window | Respond `200 OK` within 1 second |
| Retries | Mentioned but no schedule is published |
| Callback URL | Public HTTPS with a valid certificate. No query parameters. Self-signed certificates are rejected |
| Metadata | Optional object under 10 kB, echoed back in every event as `metadata.user` |
| Source IPs | Not published |

## Common events

Yapily lists the categories available to your application at runtime with `GET /webhook/events/categories`. Its product pages document these event types:

| Product | Event type | Fires when |
| --- | --- | --- |
| Payments | `single_payment.status.updated` | A single payment's status moves to `COMPLETED` or `FAILED` |
|  | `single_payment.status.completed` | A single payment's status moves to `COMPLETED` |
|  | `single_payment.iso_status.updated` | A single payment's ISO 20022 status changes, which can happen several times |
| Hosted Pages | `hosted_pis.payment_status.completed.v1` | A hosted payment completes successfully |
|  | `hosted_pis.payment_status.failed.v1` | A hosted payment fails and an error is received |
| Data Plus | `transactions.categorisation.completed` | A categorisation request finishes and the data is ready to collect |
|  | `transactions.categorisation.failed` | A categorisation request fails |

Yapily's introduction also mentions consent and data-access notifications that no page names yet, so treat the list as incomplete and acknowledge types you don't recognise. Note that the hosted payment types end in `.v1` and the others don't.

Every event shares one envelope. Here is Yapily's `hosted_pis.payment_status.failed.v1` example:

```json
{
  "type": "hosted_pis.payment_status.failed.v1",
  "applicationId": "a75620ec-8a74-408d-b13f-84227036cf43",
  "id": "b59e9533-24a0-4fd0-afc8-0c0c1a186f34",
  "event": {
    "consentId": "2427faa9-08e2-404e-90f1-4a312dc5f80d",
    "institutionId": "mock-sandbox",
    "paymentId": "9c5f7701-5275-442f-9d86-80d3ddc5cccc",
    "paymentRequestId": "dedcc4f9-7d6d-451c-accc-8a322f153a56",
    "isoStatus": { "code": "RJCT", "name": "Rejected" },
    "status": "FAILED"
  },
  "metadata": {
    "tracingId": "6cf5ef06-9f96-4f24-8bab-32ab09128010",
    "user": { "key": "value" }
  }
}

```

* `type` is the event type. Route on it.
* `applicationId` is the application, or sub-application, that registered the webhook.
* `event` is the product-specific object. Its shape depends on `type`.
* `metadata.tracingId` is Yapily's tracing ID. Quote it when you contact Yapily support.
* `metadata.user` is the metadata you supplied at registration.
* `id` is described as "the Webhook ID that triggered this event" on the introduction page, and as "the webhook delivery ID" on the Hosted Pages page. The completed and failed hosted payment examples carry the same `id`, which suggests the first reading. Don't rely on it as a per-event ID until you have seen real deliveries.

## Setting up Yapily webhooks

1. Get a Yapily access token for your application.
2. Call `GET /webhook/events/categories` and choose the categories you need.
3. Call `POST /webhook/events` with those categories, your callback URL and, optionally, a `metadata` object. The URL must be public HTTPS with a valid certificate and must not contain query parameters, or Yapily returns a `400`.
4. Copy the secret from the response and store it server-side, for example as `YAPILY_WEBHOOK_SECRET`. Yapily says this is the only call that ever shows it.

`GET /webhook/events` lists your registered webhooks and `DELETE /webhook/events/{webhook_id}` removes one. If you use Yapily's application management for resellers and merchants, send a `sub-application` header with the sub-application's ID on each of these calls.

If you use Hookdeck, create a source with the Yapily source type, set the secret on it, and register the Hookdeck source URL as the callback URL.

## Securing Yapily webhooks

Compute an HMAC-SHA256 of the raw request body with the secret as a UTF-8 string, encode it as hex, and compare it in constant time with `webhook-signature`. Yapily's own samples compare case-insensitively, so lowercase the header first. During a delayed secret reset, also check `webhook-rotation-signature` and accept the request if either one matches.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyYapilySignature(rawBody, signatureHeaders, secret) {
  if (!secret) return false;
  const expected = Buffer.from(crypto.createHmac('sha256', secret).update(rawBody).digest('hex'), 'utf8');

  return signatureHeaders.some((header) => {
    if (!header) return false;
    const received = Buffer.from(header.trim().toLowerCase(), 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/yapily', express.raw({ type: '*/*' }), (req, res) => {
  const signatures = [req.get('webhook-signature'), req.get('webhook-rotation-signature')];
  if (!verifyYapilySignature(req.body, signatures, process.env.YAPILY_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.type. Persist first, respond within 1 second, process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_yapily_signature(raw_body: bytes, signature_headers, secret: str) -> bool:
    if not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest().encode("utf-8")
    return any(
        hmac.compare_digest(header.strip().lower().encode("utf-8"), expected)
        for header in signature_headers
        if header
    )

# In FastAPI: raw_body = await request.body()
# verify_yapily_signature(raw_body,
#                         [request.headers.get("webhook-signature"),
#                          request.headers.get("webhook-rotation-signature")],
#                         os.environ["YAPILY_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `yapily-test-secret` and this exact body:

```
{"type":"single_payment.status.completed","applicationId":"e6d4bd6c-d7a2-4389-9ff7-4b8f95b6eed0","id":"14178946-c67c-417f-a773-f4f593d479a3","event":{"status":"COMPLETED"}}

```

The `webhook-signature` header should be:

```
b3b32eeca44e76e14f5a332852d989add056a96cfae88e3f79d0373fbf1ff868

```

> **Make Yapily webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `webhook-signature` header on every Yapily delivery, answers well inside the 1-second window, and durably queues payment and categorisation events before your service sees them.

## Yapily webhook limitations and pain points

### A 1-second response window

**The Problem:** Deliveries count as failed even though your handler works.

**Why It Happens:** Yapily asks you to respond `200 OK` within 1 second. Database writes, calls to other services, or a cold start easily take longer. The same page also says you would normally respond after processing, which doesn't fit in a second.

**Workarounds:**

* Verify, write the raw event to a queue or table, and return `200` immediately.
* Do all processing asynchronously.
* Keep the webhook route warm if you run on serverless.

**How Hookdeck Can Help:** Hookdeck acknowledges Yapily as soon as the request arrives and stores it, then delivers to your service without Yapily's 1-second limit, so slow processing never fails a Yapily delivery.

### Rotation and the second signature header

**The Problem:** After you reset the secret, verification starts failing on some or all deliveries.

**Why It Happens:** `POST /webhook/secrets/{webhook_id}` replaces the secret and returns the new one. With a delay, Yapily sends both `webhook-signature` and `webhook-rotation-signature` for that period, each signed with one of the two secrets. Yapily doesn't say which header carries which secret, so a verifier that reads only `webhook-signature` may fail as soon as the reset starts.

**Workarounds:**

* Always reset with a delay, and check both headers during the overlap, as in the code above.
* Update your stored secret before the delay ends.

**How Hookdeck Can Help:** Hookdeck's Yapily source type checks `webhook-signature` only, and Yapily doesn't say which secret signs that header during the overlap. Reset with a delay, watch the first deliveries in Hookdeck, and if they fail verification with the old secret, switch the source to the new one. Switch before the delay ends at the latest, and retry any deliveries that failed in between from Hookdeck.

### A Standard Webhooks verifier rejects everything

**The Problem:** You verify with a Standard Webhooks library because of the header name, and nothing passes.

**Why It Happens:** Yapily's `webhook-signature` is a bare hex HMAC of the body. Standard Webhooks expects a base64 `v1,` signature over an ID, a timestamp and the body.

**Workarounds:**

* Use a plain HMAC-SHA256 of the raw body, encoded as hex, as above.

**How Hookdeck Can Help:** Hookdeck's Yapily source type uses Yapily's own scheme, so you don't have to work around the naming collision.

### No retry schedule, and a 24-hour cut-off for payments

**The Problem:** You can't tell how long Yapily keeps trying, and some payments never produce a webhook.

**Why It Happens:** Yapily says a payment webhook might not arrive if it "fails to deliver to you after retrying", but doesn't publish how many retries or over what period. Separately, Yapily times out payment webhooks at 24 hours: if the institution hasn't updated the payment's status by then, no webhook is sent. Some institutions don't provide status updates at all.

**Workarounds:**

* Poll **Get Payment Details** for payments that have no webhook after a reasonable period, and always after 24 hours.
* Reconcile after any outage on your side, since you can't rely on retries covering it.

**How Hookdeck Can Help:** Hookdeck receives and stores every delivery even while your service is down, so an outage on your side doesn't depend on Yapily's unpublished retry policy.

## Best practices

### Verify against the raw body

Yapily's docs warn not to map the body into any structure before signing it. Read the raw bytes (`express.raw()`, `await request.body()`), verify, then parse.

### Route on `type` and expect versioned names

Switch on `type`, including the `.v1` suffix on hosted payment events. Log and acknowledge unknown types, because new categories appear through `GET /webhook/events/categories`.

### Use `metadata` to carry your own context

Whatever you put in `metadata` at registration comes back as `metadata.user` on every event. Use it to tag the environment or tenant a webhook belongs to, and keep it under 10 kB.

### Build your own idempotency key

Because `id` may identify the webhook rather than the event, combine `type` with an ID from `event`, such as the payment ID and status, and store it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat the webhook as a signal and confirm state

For payments, re-read the payment from the API before releasing goods or funds, and keep polling as a fallback for institutions that don't report status changes.

### Acknowledge fast and process asynchronously

The 1-second window leaves no room for work in the request. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Yapily signs every webhook with a hex HMAC-SHA256 of the raw body in `webhook-signature`, keyed with a secret you only see once, and adds `webhook-rotation-signature` while a delayed reset is in progress. Verify against the raw bytes, check both headers during rotation, respond within a second, and keep a polling fallback for payments, because Yapily doesn't publish its retry policy and stops waiting for status updates after 24 hours.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Yapily signature, and answers inside the 1-second window while it queues each event for your service.

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