ePostman docs
Console

Webhooks

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

4 min read
  • #webhook
  • #hmac-sha256
  • #x-webhook-signature
  • #events
  • #retry

Instead of polling, let the platform call you.

Registration

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.

Payload shape

{
  "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

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.

signature check
import { createHmac, timingSafeEqual } from "node:crypto";

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);
}

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

EventWhen it fires
invoice.deliveredThe 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.acceptedThe 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.unconfirmedNo 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.rejectedThe 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.receivedA 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.