# Webhooks

URL: https://postman.slovakodata.com/developers/en/webhooky/
Updated: 2026-10-01

Registering webhooks, payload shape, signature verification and the event catalogue.

Instead of polling, let the platform call you.

### Registration

```bash
curl -X POST https://testpostman.slovakodata.com/api/v1/webhooks \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://erp.mojafirma.sk/hooks/epostar",
    "events": ["invoice.delivered", "invoice.rejected", "invoice.accepted"]
  }'

# 201 Created — the platform generates the secret, returned only once
{
  "id": "b7e2f9a1-3c4d-4e11-9a2b-7f6c1d8e0a35",
  "url": "https://erp.mojafirma.sk/hooks/epostar",
  "events": ["invoice.delivered", "invoice.rejected", "invoice.accepted"],
  "secret": "whsec_...",
  "is_active": true,
  "created_at": "2026-07-13T09:10:00.000Z"
}
```

Pass a list of specific `events` from the catalogue below. The platform
generates the signing secret and returns it only in this creation response —
it is never shown again in the webhook list. Store it right away; if you
lose it, delete the webhook and create a new one.

The URL must start with `https://` and point to a publicly reachable server.
The platform rejects with `400` a URL without `https`, with a user name and
password in it, a single-label host name (e.g. `https://erp/hook`), internal
domains (`localhost`, `.local`, `.internal`) and IP addresses in private or
reserved ranges. It checks the same on every delivery: if the host name
resolves to such an address, the message is not delivered and not retried.
Redirects (`3xx`) are not followed.

**Managing webhooks goes through an API key, not an access token.** The
token from `/sapi/auth/token` is reserved exclusively for `/sapi/*` paths —
on `/api/v1/*` it returns `403`. Create an API key in the portal under
**Settings → API Keys**.

### Payload shape

```text
{
  "event": "invoice.delivered",
  "timestamp": "2026-07-13T09:15:03.871Z",
  "data": {
    "invoice_id": "9f2c1e84-3b7a-4d16-b0c5-1e8a72d40f31",
    "mls_c3_status": null,
    "mls_c5_status": null,
    "status": "DELIVERED",
    "selfBilling": false,
    "invoiceTypeCode": "380",
    "document_type": "INVOICE",
    "invoice_number": "2026001",
    "supplier_name": "Supplier Ltd.",
    "buyer_name": "Buyer plc.",
    "total_amount": "120.00",
    "currency": "EUR",
    "issue_date": "2026-08-23",
    "document_url": "https://postman.slovakodata.com/api/v1/invoices/9f2c1e84-3b7a-4d16-b0c5-1e8a72d40f31/xml"
  }
}

# Headers
X-Webhook-Signature: 4f1c0a...      # HMAC-SHA256, hex
X-Webhook-Timestamp: 2026-07-13T09:15:03.871Z
```

**Empty document fields are omitted from `data` — they are not sent as `null`.** Treat
them as optional and do not rely on their presence. `mls_c3_status` and `mls_c5_status`
are the exception: they are sent even without a value, and `null` means the MLS response
has not arrived yet — as in the example above, where the recipient's access point received
the message but has not sent an MLS yet. `invoice_id` is in every payload;
`selfBilling` and `document_url` are carried **only by invoice and MLS events**
(`invoice.accepted`, `invoice.rejected`, `invoice.delivered`, `invoice.received`,
`mls.received`, `mls.nok`). The `invoice.failed`, `invoice.undeliverable` and `tdd.*`
events have their own, narrower payload.

`invoiceTypeCode` is the **UBL BT-3 code exactly as it arrived in the document** —
`380` invoice, `381` credit note, `389` self-billing invoice, `261` self-billing credit
note, but also other values (e.g. `384` corrected invoice). When the code is unknown the
field is **absent**; we do not substitute `380` for it. By contrast `document_type` is our
simplified category, which files unknown codes under `INVOICE` — use `invoiceTypeCode`
to tell document types apart.

`document_url` points at the original UBL XML download. It requires authentication just
like any other `/api/v1/*` call.

### Verifying the signature

The signature is HMAC-SHA256 over the string `{timestamp}.{body}` using your
secret. **Always verify it** — otherwise anyone can forge an event. Compare
with a timing-safe function.

```ts

export function isValidWebhook(
  rawBody: string,      // the body EXACTLY as received — not re-serialized
  signature: string,    // X-Webhook-Signature
  timestamp: string,    // X-Webhook-Timestamp
  secret: string,
): boolean {
  // Discard anything older than 5 minutes — replay protection.
  // The header is ISO 8601, not epoch: Number() would yield NaN and the
  // comparison would always pass, silently disabling the protection.
  const sentAt = Date.parse(timestamp);
  if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > 300_000) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);

  return a.length === b.length && timingSafeEqual(a, b);
}
```

```csharp
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

static bool IsValidWebhook(
    string rawBody, string signature, string timestamp, string secret)
{
    // Discard anything older than 5 minutes — replay protection.
    if (!DateTimeOffset.TryParse(timestamp, CultureInfo.InvariantCulture,
                                 DateTimeStyles.RoundtripKind, out var sentAt)) return false;
    if (Math.Abs((DateTimeOffset.UtcNow - sentAt).TotalMilliseconds) > 300_000) return false;

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));

    var payload  = Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}");
    var expected = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant();

    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(signature));
}
```

```php
function isValidWebhook(
    string $rawBody,
    string $signature,
    string $timestamp,
    string $secret
): bool {
    // Discard anything older than 5 minutes — replay protection.
    $sentAt = strtotime($timestamp);
    if ($sentAt === false || abs(time() - $sentAt) > 300) {
        return false;
    }

    $expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);

    return hash_equals($expected, $signature);
}
```

**We sign exactly what we send.** Compute the signature from the *raw*
request body. If you parse the body into an object and re-serialise it, key
order or whitespace can shift and the signature will no longer match.

### Delivery and retries

We wait at most **30 seconds** for a response. Return `2xx` as soon as you
receive the message and process it in the background afterwards. If your
endpoint fails or times out, the delivery is queued for a retry.

**Retries are signed exactly like the first attempt** — same recipe, same header
shape. Only the `timestamp` differs: every attempt carries a fresh one, which is
also inside the signed body, so the five-minute window applies to that attempt
rather than to the original event. Verification written against the first
delivery therefore holds for every retry.

### Event catalogue

| Event | When it fires |
|---|---|
| `invoice.delivered` | The recipient's access point received the outgoing invoice and confirmed it with a positive AS4 receipt: `status: "DELIVERED"`, `mls_c3_status: null`. The invoice has been sent, delivery is not confirmed yet and the credit is still reserved. If the MLS message arrives before we send this event, it is usually not sent and `invoice.accepted` or `invoice.rejected` follows directly. For a recipient outside the Peppol network: the tax authority accepted the tax report, `status: "DELIVERED_NON_PEPPOL"` — here this is the final state and the credit is deducted. |
| `invoice.accepted` | The recipient's access point sent a positive MLS message — the invoice is delivered: `status: "ACCEPTED"`, `mls_c3_status: "OK"`. The reserved credit is deducted at that point. It does not mean the buyer approved the invoice commercially. |
| `invoice.unconfirmed` | No MLS message arrived within 25 minutes of sending; the invoice moved to `UNCONFIRMED` and the credit was deducted. If the MLS message arrives later, `invoice.accepted` or `invoice.rejected` still follows. The payload carries only `invoice_id` and `reason`. |
| `invoice.rejected` | The recipient's access point rejected the invoice with a negative MLS message — or, for a recipient outside Peppol, the tax authority rejected the tax report. |
| `mls.received` | A tax-reporting response arrived while confirmation from the other side is still pending. The invoice state does not change. |

**Event order is not guaranteed.** `invoice.delivered` can occasionally arrive after
`invoice.accepted` or `invoice.rejected` for the same invoice. Decide the invoice state by
the `status` field in `data`, not by the event name or order: `DELIVERED` never overwrites a
final state you have already received, and the same `invoice.delivered` name carries
`DELIVERED` for Peppol and `DELIVERED_NON_PEPPOL` outside Peppol.
