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