Webhooks Guide
Webhooks let you receive real-time HTTP callbacks when events happen in your Mandato account — invoices being accepted, rejected, or encountering errors, and connections expiring. Instead of polling the API for status changes, webhooks push updates to your server as they happen.
How webhooks work
Section titled “How webhooks work”- You register a webhook URL with Mandato
- When an event occurs (e.g., an invoice is accepted by ANAF), Mandato sends an HTTP
POSTto your URL - The request body contains the event type and relevant data
- The request is signed with HMAC-SHA256 so you can verify it came from Mandato
- If your endpoint returns a non-2xx status, Mandato retries with exponential backoff
Setting up webhooks
Section titled “Setting up webhooks”-
Register your webhook URL
Use the API or SDK to register your endpoint:
import { MandatoClient } from "@getmandato/sdk";const mandato = new MandatoClient({apiKey: "sk_test_your_key",});const { data: webhook } = await mandato.webhooks.create({url: "https://your-app.com/webhooks/mandato",events: ["invoice.accepted","invoice.rejected","invoice.error","connection.expired",],});// IMPORTANT: Store the secret securely -- it is only shown onceconsole.log("Webhook secret:", webhook.secret);// wh_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 -
Build your webhook endpoint
Create an HTTP endpoint that accepts
POSTrequests. The endpoint must:- Accept
application/jsonbodies - Verify the
X-Mandato-Signatureheader - Return a
200status code within 30 seconds - Be idempotent (handle duplicate deliveries gracefully)
import express from "express";import { constructWebhookEvent } from "@getmandato/sdk";const app = express();// IMPORTANT: Use raw body for signature verificationapp.post("/webhooks/mandato",express.raw({ type: "application/json" }),(req, res) => {const signature = req.headers["x-mandato-signature"] as string;const payload = req.body.toString();try {const event = constructWebhookEvent(payload,signature,process.env.MANDATO_WEBHOOK_SECRET!,);switch (event.type) {case "invoice.accepted":console.log("Invoice accepted:", event.data.id);console.log("Government ID:", event.data.govId);// Update your database, notify the user, etc.break;case "invoice.rejected":console.log("Invoice rejected:", event.data.id);console.log("Error:", event.data.errorTranslated);// Alert the user, queue for retry, etc.break;case "invoice.error":console.log("Invoice error:", event.data.id);// Log the error, alert on-call, etc.break;case "connection.expired":console.log("Connection expired:", event.data.id);// Prompt the user to re-authorizebreak;default:console.log("Unhandled event type:", event.type);}res.status(200).json({ received: true });} catch (err) {console.error("Webhook verification failed:", err);res.status(400).json({ error: "Invalid signature" });}},);app.listen(3000);import { Hono } from "hono";import { constructWebhookEvent } from "@getmandato/sdk";const app = new Hono();app.post("/webhooks/mandato", async (c) => {const signature = c.req.header("x-mandato-signature");if (!signature) {return c.json({ error: "Missing signature" }, 400);}const payload = await c.req.text();try {const event = constructWebhookEvent(payload,signature,process.env.MANDATO_WEBHOOK_SECRET!,);switch (event.type) {case "invoice.accepted":console.log("Invoice accepted:", event.data.id);break;case "invoice.rejected":console.log("Invoice rejected:", event.data.id);break;case "invoice.error":console.log("Invoice error:", event.data.id);break;case "connection.expired":console.log("Connection expired:", event.data.id);break;}return c.json({ received: true }, 200);} catch {return c.json({ error: "Invalid signature" }, 400);}});export default app;app/api/webhooks/mandato/route.ts import { constructWebhookEvent } from "@getmandato/sdk";import { NextRequest, NextResponse } from "next/server";export async function POST(request: NextRequest) {const signature = request.headers.get("x-mandato-signature");if (!signature) {return NextResponse.json({ error: "Missing signature" }, { status: 400 });}const payload = await request.text();try {const event = constructWebhookEvent(payload,signature,process.env.MANDATO_WEBHOOK_SECRET!,);switch (event.type) {case "invoice.accepted":// Handle accepted invoicebreak;case "invoice.rejected":// Handle rejected invoicebreak;case "invoice.error":// Handle invoice errorbreak;case "connection.expired":// Handle expired connectionbreak;}return NextResponse.json({ received: true });} catch {return NextResponse.json({ error: "Invalid signature" },{ status: 400 },);}} - Accept
-
Test with the sandbox
In the sandbox, webhooks fire with the same payloads as production. Submit a test invoice and verify your endpoint receives the events:
// Submit an invoice with a test keyconst { data: invoice } = await mandato.invoices.create({country: "RO",supplier: { vatNumber: "RO12345678", name: "Test SRL" },customer: { vatNumber: "RO87654321", name: "Customer SRL" },lines: [{ description: "Test item", unitPrice: 100, vatRate: 19 }],});// Your webhook endpoint will receive:// 1. invoice.created// 2. invoice.validated// 3. invoice.submitted// 4. invoice.accepted (after ~10 seconds)
Signature verification
Section titled “Signature verification”Every webhook delivery includes an X-Mandato-Signature header containing an HMAC-SHA256 signature of the request body. Always verify this signature before processing the event.
How the signature is computed
Section titled “How the signature is computed”HMAC-SHA256(webhook_secret, raw_request_body) -> hex stringThe signature is the hex-encoded HMAC-SHA256 digest of the raw request body using your webhook secret as the key.
Using the SDK
Section titled “Using the SDK”The SDK provides two functions for signature verification:
verifyWebhookSignature
Section titled “verifyWebhookSignature”Returns true or false without parsing the body:
import { verifyWebhookSignature } from "@getmandato/sdk";
const isValid = verifyWebhookSignature( rawBody, // raw request body string request.headers["x-mandato-signature"], // signature header process.env.MANDATO_WEBHOOK_SECRET!, // your webhook secret);
if (!isValid) { return res.status(400).send("Invalid signature");}constructWebhookEvent
Section titled “constructWebhookEvent”Verifies the signature and parses the event in one step. Throws an error if the signature is invalid:
import { constructWebhookEvent } from "@getmandato/sdk";
try { const event = constructWebhookEvent(rawBody, signature, secret); // event is verified and parsed} catch (err) { // Signature verification failed}Manual verification (without the SDK)
Section titled “Manual verification (without the SDK)”If you are not using the Node.js SDK, verify signatures manually:
# Python exampleimport hmacimport hashlib
def verify_signature(payload: bytes, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode("utf-8"), payload, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature)// Go examplepackage main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex")
func verifySignature(payload []byte, signature string, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(payload) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature))}Event types
Section titled “Event types”Invoice events
Section titled “Invoice events”| Event | Description | Data includes |
|---|---|---|
invoice.created | Invoice was created and queued | Invoice object |
invoice.validated | Invoice passed all validation rules | Invoice object |
invoice.submitted | Invoice was uploaded to the government system | Invoice object |
invoice.accepted | Government accepted the invoice | Invoice object with govId |
invoice.rejected | Government rejected the invoice | Invoice object with errorMessage, errorTranslated, errorFix |
invoice.error | Internal processing error | Invoice object with errorMessage |
Connection events
Section titled “Connection events”| Event | Description | Data includes |
|---|---|---|
connection.active | Connection was successfully activated | Connection object |
connection.expired | Connection token expired, re-authorization needed | Connection object |
Event payload format
Section titled “Event payload format”Every webhook event follows this structure:
{ "id": "evt_a1b2c3d4e5f6", "type": "invoice.accepted", "createdAt": "2025-01-15T10:00:15.000Z", "data": { "id": "inv_a1b2c3d4e5f6", "country": "RO", "supplierVat": "RO12345678", "customerVat": "RO87654321", "status": "accepted", "netAmount": "5200.00", "vatAmount": "988.00", "grossAmount": "6188.00", "currency": "RON", "govId": "4523789012", "externalId": "order-12345" }}| Field | Type | Description |
|---|---|---|
id | string | Unique event identifier |
type | string | Event type (see tables above) |
createdAt | string | ISO 8601 timestamp when the event occurred |
data | object | Event-specific data (invoice or connection object) |
Delivery headers
Section titled “Delivery headers”| Header | Description |
|---|---|
Content-Type | application/json |
X-Mandato-Signature | HMAC-SHA256 signature of the body |
X-Mandato-Event | Event type (e.g., invoice.accepted) |
X-Mandato-Delivery-Id | Unique delivery ID for deduplication |
Retry behavior
Section titled “Retry behavior”If your endpoint returns a non-2xx status code or the request times out (30-second limit), Mandato retries with exponential backoff:
| Attempt | Delay | Total elapsed |
|---|---|---|
| Initial delivery | Immediate | 0 |
| 1st retry | 1 minute | 1 minute |
| 2nd retry | 5 minutes | 6 minutes |
| 3rd retry | 30 minutes | 36 minutes |
| 4th retry | 2 hours | ~2.5 hours |
| 5th retry | 12 hours | ~14.5 hours |
After 5 failed retries, the delivery is permanently marked as failed. Failed deliveries are visible in the dashboard.
Handling duplicate deliveries
Section titled “Handling duplicate deliveries”Webhooks are delivered at least once. Your endpoint may receive the same event more than once due to retries or network issues. Design your handler to be idempotent:
// Track processed delivery IDsconst processedDeliveries = new Set<string>();
app.post("/webhooks/mandato", async (req, res) => { const deliveryId = req.headers["x-mandato-delivery-id"] as string;
// Skip if already processed if (processedDeliveries.has(deliveryId)) { return res.status(200).json({ received: true, deduplicated: true }); }
// Verify and process... const event = constructWebhookEvent(payload, signature, secret);
// Mark as processed (in production, use a database, not an in-memory set) processedDeliveries.add(deliveryId);
// Handle the event... res.status(200).json({ received: true });});In production, store processed delivery IDs in your database with a unique constraint to guarantee idempotency.
Filtering events
Section titled “Filtering events”When registering a webhook, you can subscribe to specific event types. This reduces noise and processing overhead:
// Only receive final status eventsawait mandato.webhooks.create({ url: "https://your-app.com/webhooks/mandato", events: ["invoice.accepted", "invoice.rejected", "invoice.error"],});If you omit the events field, you receive all event types.
Updating your webhook
Section titled “Updating your webhook”Calling POST /v1/webhooks replaces the existing configuration. A new signing secret is generated each time.
// Update URL and events (generates a new secret)const { data: updated } = await mandato.webhooks.create({ url: "https://new-endpoint.com/webhooks", events: ["invoice.accepted", "invoice.rejected"],});
// Update your stored secretconsole.log("New secret:", updated.secret);Best practices
Section titled “Best practices”-
Always verify signatures — Never skip verification, even in development. This prevents spoofed events from triggering actions in your system.
-
Respond quickly — Return
200within 30 seconds. If you need to do heavy processing, acknowledge the webhook immediately and process asynchronously. -
Handle duplicates — Use the
X-Mandato-Delivery-Idheader to deduplicate. Your handler should be safe to call multiple times with the same event. -
Use HTTPS — Webhook URLs must use HTTPS in production. HTTP is only allowed in the sandbox for local development.
-
Monitor failures — Check the dashboard for failed deliveries. Persistent failures may indicate issues with your endpoint.
-
Subscribe selectively — Only subscribe to the events you actually handle. This reduces unnecessary traffic and processing.
-
Log events — Log every webhook event with the delivery ID and event type for debugging.
console.log("Webhook received", { deliveryId: req.headers["x-mandato-delivery-id"], event: event.type, resourceId: event.data.id, timestamp: event.createdAt,});