> ## Documentation Index
> Fetch the complete documentation index at: https://help.lobyco.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Data streams: receive webhooks

> Build the endpoint that receives Data streams events: check the credentials and the signature, store each event once, answer in time and handle retries.

Data streams send Lobyco events to your endpoint as signed, authenticated HTTPS `POST` requests, one request per event. This guide is for the developer who builds that endpoint. An integration admin [sets up the subscription in Lobyco Nexus](https://help.lobyco.com/integration/external-integrations/data-streams/set-up-a-subscription.md) and gives you its signing key.

> [!WARNING]
>
> Lobyco delivers every event at least once. The same event can reach you twice, so your endpoint must recognise a repeat and skip it. [Use the idempotency key](#use-the-idempotency-key) says how.

## Before you start

* **A callback URL.** Give the admin an HTTPS address at a public host name. Nexus refuses an IP address, and Lobyco refuses an address in a private network when the subscription is saved and again on every request.
* **An authentication method.** Agree with the admin how Lobyco signs in to your endpoint: OAuth 2.0 client credentials, HTTP Basic or custom headers. The admin stores it in Nexus as the subscription's auth integration. [Authentication](https://help.lobyco.com/integration/external-integrations/outboundapi-connection.md#authentication) describes the three types.
* **The signing key.** Nexus shows it once, when the subscription is saved; get it from the admin. It is a base64 string. Keep it in a secret store, never in source code.
* **A durable store.** A table or a queue where your endpoint keeps each event before it answers. Lobyco recommends one keyed on the delivery id, as in [Use the idempotency key](#use-the-idempotency-key).

## Handle a delivery

```mermaid theme={"system"}
flowchart LR
    A["Lobyco sends<br/>one event"] --> B{"Credentials and<br/>signature valid?"}
    B -->|"No"| C["Answer 401"]
    B -->|"Yes"| D{"Delivery id<br/>seen before?"}
    D -->|"Yes"| E["Answer 2xx,<br/>do nothing"]
    D -->|"No"| F["Store event,<br/>answer 2xx"]
    F --> G["Process it<br/>later"]
```

Your endpoint does the same for every request:

**Step 1**

### Read the request without parsing it

Read the headers, and read the body as raw bytes. Do not parse the JSON yet: the signature covers the exact bytes Lobyco sent, and parsing and re-serialising changes them. [The request](#the-request) lists every header.

**Step 2**

### Check the credentials

Check the `Authorization` header, or your custom headers, against the credentials in the subscription's auth integration. Answer `401` to a request without valid credentials. This keeps anonymous callers out before your endpoint does any other work.

**Step 3**

### Check the signature

Compute the HMAC-SHA256 of `<t>.<webhook id>.<raw body>` with the signing key, and compare it with every `v1` value in `x-lobyco-signature`. Answer `401` when none matches. [Verify the signature](#verify-the-signature) has the details, code and a test vector.

**Step 4**

### Claim the delivery id

Insert the `x-lobyco-webhook-id` value into your store in one atomic write. If the id is already there, this is a repeat: answer `2xx` and stop. [Use the idempotency key](#use-the-idempotency-key) has the rules.

**Step 5**

### Store the event and answer 2xx

Store the raw body with its id, then answer any `2xx` status. Lobyco ignores the response body. Answer within 10 seconds, counted from the start of the attempt, including Lobyco's sign-in to your endpoint. A later `2xx` counts as a timeout, and Lobyco sends the event again.

**Step 6**

### Process the event from your store

Process the event after you have answered, from your store. Route it on `x-lobyco-event`, for example `offer.redeemed`. Skip `webhook.test`, which is a [test request](#recognise-test-requests) from Nexus. Do not rely on the order events arrive in: see [Handle events out of order](#handle-events-out-of-order).

**Step 7**

### Check it works with a test

Ask the admin to select **Test** on the subscription in Nexus. Nexus shows your status code and response time. A **Success** with your `2xx` means the credentials, the address and your signature check all work.

Then ask for **Test payload** on one of the subscription's events. Your endpoint gets a sample body in the shape it will receive for that event, again marked `webhook.test`.

## The request

```http theme={"system"}
POST /webhooks/lobyco HTTP/1.1
Host: cdp.example.com
Content-Type: application/json; charset=utf-8
Authorization: Bearer <access token>
User-Agent: Lobyco-Webhooks/1.0
Accept: */*
x-lobyco-event: offer.redeemed
x-lobyco-webhook-id: 9c36b36d1f50d772916352c6499fd695ebdece031df828e05aa454c02b34e7fe
x-lobyco-timestamp: 2026-09-30T10:00:00.0000000Z
x-lobyco-signature: t=1759226401, v1=<64 lowercase hex characters>

<the event's data, as published, or the JSON of the payload transformation>
```

| Header | What it holds |
| - | - |
| `Content-Type` | Always `application/json; charset=utf-8`. |
| `Authorization`, or your custom headers | Set by the subscription's auth integration: `Bearer <token>` for OAuth 2.0 client credentials, `Basic <credentials>` for HTTP Basic, or the names and values defined for custom headers. Every delivery carries them. |
| `User-Agent` | Always `Lobyco-Webhooks/1.0`. It does not change with Lobyco releases, so you can allow it in a web application firewall. |
| `Accept` | Always `*/*`. |
| `x-lobyco-event` | The event type, for example `offer.redeemed`, or `webhook.test` for a test. The [Events](https://help.lobyco.com/integration/external-integrations/events) catalogues list the Data streams types and availability. |
| `x-lobyco-webhook-id` | The delivery id: 64 lowercase hex characters. It is the same on every retry and on every resend by Lobyco Support. Use it as your idempotency key. |
| `x-lobyco-timestamp` | When the event happened, ISO 8601 in UTC. It is the same on every retry, so it is not the send time. |
| `x-lobyco-signature` | `t=<unix seconds>, v1=<hex>`. `t` is when Lobyco sent this attempt. See [Verify the signature](#verify-the-signature). |

The four `x-lobyco-*` header names are lower case and reserved. A custom-headers integration cannot override them. It can set its own `User-Agent` or `Accept`, which then replaces Lobyco's.

### The body

By default, the body is the event's data exactly as the owning service published it. Lobyco does not wrap it in an envelope, rename fields or add fields: everything Lobyco adds is in the headers. The body therefore matches the event's schema in the [Events](https://help.lobyco.com/integration/external-integrations/events) catalogues.

If the admin set a [payload transformation](https://help.lobyco.com/integration/external-integrations/data-streams/set-up-a-subscription.md#write-a-payload-transformation) for the event, the body is the JSON it produces instead. The headers are the same either way, and the signature covers the body actually sent.

The publishing services follow different conventions, and your parser must accept all of them:

| Source | Property names | Empty values | Event types |
| - | - | - | - |
| Lobyco Nexus: campaigns and Inbound API blocks | camelCase | Left out | `campaign.*`, `inbound_api.*` |
| Games, definitions | PascalCase | Left out | `game.created`, `game.updated`, `game.published`, `game.stopped`, `game.deleted` |
| Promotion, Challenges, game attempts, Members and Consent | PascalCase | Sent as `null` | `offer.*`, `challenge.*`, `game.started`, `game.finished`, `member.*`, `consent.*`, `loyalty_card.*` |

## Verify the signature

Lobyco signs every delivery with HMAC-SHA256 and the subscription's signing key. Signing cannot be switched off. Check the signature before you trust the body.

* **What is signed:** the text `<t>.<webhook id>.` followed by the raw request body. `<t>` is the `t` value from `x-lobyco-signature`, and `<webhook id>` is the `x-lobyco-webhook-id` header.
* **The key:** base64-decode the signing key and use the resulting 32 bytes as the HMAC key. Do not use the text of the base64 string.
* **The header:** `t=<unix seconds>, v1=<hex>`, with a comma and a space between entries. While the admin rotates the key, the header carries one `v1` entry per valid key. Accept the request if any entry matches.

Step by step:

1. Read the raw body before any JSON parsing.
2. Split `x-lobyco-signature` on commas, trim each part, and read `t` and every `v1` value.
3. Reject the request if `t` is more than 5 minutes from your clock. Lobyco recommends this replay window but does not enforce one. A retry carries a fresh `t`.
4. Compute `HMAC-SHA256(key, "<t>." + webhook id + "." + raw body)` as lowercase hex.
5. Compare it with each `v1` value in constant time. Accept the request if one matches.

The signature covers `t`, `x-lobyco-webhook-id` and the body. It does not cover `x-lobyco-event` or `x-lobyco-timestamp`. Apply your replay window to `t`, never to `x-lobyco-timestamp`, and treat `x-lobyco-event` as routing, not as proof.

```js Node.js theme={"system"}
const crypto = require('node:crypto');

function verifyLobycoSignature(rawBody, header, webhookId, secretBase64, toleranceSeconds = 300) {
  const parts = header.split(',').map((part) => part.trim());
  const t = parts.find((part) => part.startsWith('t='))?.slice(2);
  const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
  if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;

  const key = Buffer.from(secretBase64, 'base64');
  const expected = crypto.createHmac('sha256', key)
    .update(`${t}.${webhookId}.`)
    .update(rawBody)
    .digest();

  return signatures.some((signature) => {
    if (!/^[0-9a-f]{64}$/.test(signature)) return false;
    return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), expected);
  });
}

// Express: keep the raw body, and verify before parsing it.
// app.post('/webhooks/lobyco', express.raw({ type: 'application/json' }), (req, res) => {
//   const ok = verifyLobycoSignature(req.body, req.get('x-lobyco-signature') ?? '',
//     req.get('x-lobyco-webhook-id') ?? '', process.env.LOBYCO_WEBHOOK_KEY);
//   if (!ok) return res.sendStatus(401);
//   // Claim the id, store the event, then:
//   res.sendStatus(202);
// });
```

```python Python theme={"system"}
import base64, hashlib, hmac, time

def verify_lobyco_signature(raw_body: bytes, header: str, webhook_id: str,
                            secret_b64: str, tolerance_s: int = 300) -> bool:
    parts = [p.strip() for p in header.split(",")]
    t = next((p[2:] for p in parts if p.startswith("t=")), None)
    signatures = [p[3:] for p in parts if p.startswith("v1=")]
    if t is None or not t.isdigit() or not signatures:
        return False
    if abs(time.time() - int(t)) > tolerance_s:
        return False
    key = base64.b64decode(secret_b64)
    signed = t.encode() + b"." + webhook_id.encode() + b"." + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)
```

```csharp C# theme={"system"}
using System.Security.Cryptography;
using System.Text;

public static bool VerifyLobycoSignature(byte[] rawBody, string header, string webhookId,
    string secretBase64, TimeSpan tolerance)
{
    string? t = null;
    var signatures = new List<string>();

    foreach (var part in header.Split(',', StringSplitOptions.TrimEntries))
    {
        if (part.StartsWith("t=")) t = part[2..];
        else if (part.StartsWith("v1=")) signatures.Add(part[3..]);
    }

    if (t is null || signatures.Count == 0 || !long.TryParse(t, out var unixSeconds))
        return false;

    var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
    if (age.Duration() > tolerance)
        return false;

    var key = Convert.FromBase64String(secretBase64);
    var signed = Encoding.UTF8.GetBytes($"{t}.{webhookId}.").Concat(rawBody).ToArray();
    var expected = HMACSHA256.HashData(key, signed);

    foreach (var signature in signatures)
    {
        byte[] given;
        try { given = Convert.FromHexString(signature); }
        catch (FormatException) { continue; }

        if (CryptographicOperations.FixedTimeEquals(given, expected))
            return true;
    }

    return false;
}
```

### Test your implementation

Both rows use the raw body `{"offerId":"offer-1"}`, `t` = `1758182400` and the webhook id `9c36b36d1f50d772916352c6499fd695ebdece031df828e05aa454c02b34e7fe`.

| Signing key, base64 | Expected `v1` |
| - | - |
| `dHVyYm9mbG93LXdlYmhvb2tzLXNpZ25pbmcta2V5ISE=` | `4fcc7673140675a0370d67a11428b220b82a55cecab801fec322731422e4aba4` |
| `dHVyYm9mbG93LXdlYmhvb2tzLXByZXZpb3VzLWtleSE=` | `fd956791d4c8c94ea8e3fc609a7db1732217a39b04d9a653ac5569a49aa9f597` |

During a rotation from the second key to the first, the header is `t=1758182400, v1=4fcc76…e4aba4, v1=fd9567…a9f597`. A check with either key must accept it.

This `t` is in the past, so switch off the replay window for this test only, never in production.

### Rotate the key

The admin can generate a new signing key in Nexus at any time, and Nexus shows it once. The previous key stays valid for 24 hours. During that time, every delivery carries a `v1` signature for each of the two keys.

1. Add the new key next to the old one, and accept a request that matches either.
2. Remove the old key within 24 hours.

**If the key has leaked**, skip the overlap. Ask the admin to select **Generate new key** twice, which stops the leaked key at once, and load the key shown last straight away. Until you load it, your signature check rejects every delivery.

## Use the idempotency key

`x-lobyco-webhook-id` is the idempotency key. Lobyco sets it once per subscription and published event. It repeats it unchanged on every retry and on every resend by Lobyco Support.

1. **Verify the signature first.** The signature covers the id, so the id on a request that passes the check is genuine. Never record the id of a request that fails it.
2. **Claim the id in one atomic write.** Insert it into a table keyed on the id, or use a conditional put. If the write succeeds, this is the first copy. If it conflicts, it is a repeat. Do not read first and write later: a second copy can arrive while you still handle the first, for example after a timeout.
3. **Answer `2xx` to a repeat.** Any other answer counts as a failure: Lobyco retries the delivery or sets it aside, as in [Failure and edge cases](#failure-and-edge-cases).
4. **Do not let the check lose an event.** Store the event together with its id and process it afterwards. Or record the id in the same transaction as the work it triggers. If you record the id and the work then fails, the retry is skipped as a repeat and the event is lost.
5. **Keep the ids for at least 30 days.** Retries run for about 45 hours, and Lobyco Support can resend a set-aside delivery for up to 30 days, with the same id. Expiring ids after 31 days covers both.

The id is per subscription. Two subscriptions that send the same events to your endpoint deliver each event once each, under different ids. Avoid that set-up, or also deduplicate on the event's content.

Rarely, the service that owns an event publishes it twice. Lobyco then delivers it twice, under two different ids. If a repeat would do harm, also check the event's content, such as the customer id and the offer or member id with its timestamp. Do not rely on the event's own `EventId` alone: on `member.updated` it changes when the event is published again.

## Handle events out of order

Do not rely on the order events arrive in.

* Events about the same customer normally arrive in the order they happened. A retried delivery can still be overtaken by a later event, and a resend by Lobyco Support can arrive days later.
* Definition events have no order at all. An `inbound_api.updated` can arrive before the `inbound_api.created` of the same Inbound API block.
* A member's loyalty-card events are ordered per card. They can arrive before or after that member's other events.

Definition events (`*.created`, `*.updated`), every `inbound_api.*` event and `member.updated` carry the full current state of the item. Keeping the newest version you have received is therefore safe. Decide which version is newer from the time of the change, never from the order of arrival:

* For most events, compare the event's timestamp.
* For an Inbound API event, compare the time of its own change per `triggerId`: `createdAt`, `updatedAt`, `publishedAt`, `stoppedAt` or `deletedAt`.

Campaign and Inbound API times are to the second. Two changes in the same second carry the same time and can arrive in either order.

## Recognise test requests

The admin can test a subscription from Nexus in two ways. Both send one request, signed and authenticated like a real delivery, with `x-lobyco-event: webhook.test`.

* **Test** sends a fixed body:

  ```json theme={"system"}
  {"event":"webhook.test","subscriptionId":"<subscription id>","timestamp":"2026-09-30T10:00:00.0000000Z"}
  ```

* **Test payload** sends a sample of one of the subscription's events. It has real field names and made-up values, run through the event's saved payload transformation. Only the `x-lobyco-event` header tells it apart from a real event.

Check that header, and never process a `webhook.test` request as real data. Verify its signature and answer `2xx`, like any other request. Test requests are never retried.

## Secure the endpoint

* **Protect personal data.** Check the data carried by [customer events](https://help.lobyco.com/integration/external-integrations/events/customer-events.md) and [Inbound API events](https://help.lobyco.com/integration/external-integrations/events/inbound-api-trigger-events.md). A delivery Lobyco could not deliver is kept, body included, for 30 days.
* **Revoke credentials through the admin.** To stop Lobyco using a set of credentials, ask the admin to switch the subscriptions to another auth integration, or to disable them. Deleting the integration is not enough: see [Manage a subscription](https://help.lobyco.com/integration/external-integrations/data-streams/set-up-a-subscription.md#manage-a-subscription).

## Limits

* **No delivery history for you.** You cannot see your failed deliveries or request a replay. Lobyco Support handles deliveries that were set aside.
* **No automatic pause.** A subscription stays enabled even if every delivery fails, until an admin disables it.
* **No rate limit per subscription.** Lobyco limits how many deliveries run at the same time and honours `Retry-After`. Answer `429` with `Retry-After` to slow Lobyco down.
* **Managed in Nexus only.** There is no API to manage subscriptions.

## Failure and edge cases

Your answer decides what Lobyco does next:

| Your answer | What Lobyco does |
| - | - |
| `2xx` | Marks the delivery done. |
| `408`, `425`, `429`, any `5xx`, a timeout, or a DNS or connection failure | Retries, waiting longer each time. `Retry-After` is honoured, up to 1 hour per attempt. |
| `404` | Sets the delivery aside without retrying. Lobyco can switch on retrying `404` for all the subscriptions of your environment, for a receiver behind an integration platform that answers `404` while a workflow is stopped. Ask Lobyco whether it is on. |
| Any other `4xx`, including `401`, `403` and `413` | Sets the delivery aside without retrying. |
| `3xx` | Sets the delivery aside without retrying. Lobyco does not follow redirects, so give the admin the final URL. |

Lobyco's sign-in to your endpoint follows the same rule. If your identity provider is unavailable, with a timeout, a `429` or a `5xx`, Lobyco retries. If it refuses the credentials or answers `404`, the delivery is set aside without retrying.

A delivery has up to 10 attempts. Before each retry, Lobyco waits a little longer, give or take 20%:

| Attempt | Wait before it |
| - | - |
| 1 | None: sent as the event happens |
| 2 | 10 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 10 minutes |
| 6 | 30 minutes |
| 7 | 2 hours |
| 8 | 6 hours |
| 9 | 12 hours |
| 10 | 24 hours |

That is a window of about 45 hours. If every attempt fails, the delivery is set aside.

* **Set-aside deliveries are kept for 30 days**, then deleted. Lobyco Support can resend them once the cause is fixed, with the same `x-lobyco-webhook-id`.
* **Disabling discards waiting deliveries.** During an outage, ask the admin to leave the subscription enabled: see [Manage a subscription](https://help.lobyco.com/integration/external-integrations/data-streams/set-up-a-subscription.md#manage-a-subscription).
* **A failed payload transformation sends nothing.** When the transformation cannot produce valid JSON for an event, Lobyco sets the delivery aside without calling your endpoint. Ask the admin to fix it, then ask Lobyco Support to resend.

## Related

* [Data streams: set up a subscription](https://help.lobyco.com/integration/external-integrations/data-streams/set-up-a-subscription.md) — what the admin does in Nexus: events, auth integration, signing key, tests and enabling.
* [Events](https://help.lobyco.com/integration/external-integrations/events) — event types, Data streams availability, timing and JSON Schemas.
* [OutboundAPI connection](https://help.lobyco.com/integration/external-integrations/outboundapi-connection.md) — the auth integration types, and how a campaign calls your system at one chosen step.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.