Skip to content

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.

  1. You register a webhook URL with Mandato
  2. When an event occurs (e.g., an invoice is accepted by ANAF), Mandato sends an HTTP POST to your URL
  3. The request body contains the event type and relevant data
  4. The request is signed with HMAC-SHA256 so you can verify it came from Mandato
  5. If your endpoint returns a non-2xx status, Mandato retries with exponential backoff
  1. 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 once
    console.log("Webhook secret:", webhook.secret);
    // wh_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
  2. Build your webhook endpoint

    Create an HTTP endpoint that accepts POST requests. The endpoint must:

    • Accept application/json bodies
    • Verify the X-Mandato-Signature header
    • Return a 200 status 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 verification
    app.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-authorize
    break;
    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);
  3. 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 key
    const { 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)

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.

HMAC-SHA256(webhook_secret, raw_request_body) -> hex string

The signature is the hex-encoded HMAC-SHA256 digest of the raw request body using your webhook secret as the key.

The SDK provides two functions for signature verification:

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

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
}

If you are not using the Node.js SDK, verify signatures manually:

# Python example
import hmac
import 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 example
package 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))
}
EventDescriptionData includes
invoice.createdInvoice was created and queuedInvoice object
invoice.validatedInvoice passed all validation rulesInvoice object
invoice.submittedInvoice was uploaded to the government systemInvoice object
invoice.acceptedGovernment accepted the invoiceInvoice object with govId
invoice.rejectedGovernment rejected the invoiceInvoice object with errorMessage, errorTranslated, errorFix
invoice.errorInternal processing errorInvoice object with errorMessage
EventDescriptionData includes
connection.activeConnection was successfully activatedConnection object
connection.expiredConnection token expired, re-authorization neededConnection object

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"
}
}
FieldTypeDescription
idstringUnique event identifier
typestringEvent type (see tables above)
createdAtstringISO 8601 timestamp when the event occurred
dataobjectEvent-specific data (invoice or connection object)
HeaderDescription
Content-Typeapplication/json
X-Mandato-SignatureHMAC-SHA256 signature of the body
X-Mandato-EventEvent type (e.g., invoice.accepted)
X-Mandato-Delivery-IdUnique delivery ID for deduplication

If your endpoint returns a non-2xx status code or the request times out (30-second limit), Mandato retries with exponential backoff:

AttemptDelayTotal elapsed
Initial deliveryImmediate0
1st retry1 minute1 minute
2nd retry5 minutes6 minutes
3rd retry30 minutes36 minutes
4th retry2 hours~2.5 hours
5th retry12 hours~14.5 hours

After 5 failed retries, the delivery is permanently marked as failed. Failed deliveries are visible in the dashboard.

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 IDs
const 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.

When registering a webhook, you can subscribe to specific event types. This reduces noise and processing overhead:

// Only receive final status events
await 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.

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 secret
console.log("New secret:", updated.secret);
  1. Always verify signatures — Never skip verification, even in development. This prevents spoofed events from triggering actions in your system.

  2. Respond quickly — Return 200 within 30 seconds. If you need to do heavy processing, acknowledge the webhook immediately and process asynchronously.

  3. Handle duplicates — Use the X-Mandato-Delivery-Id header to deduplicate. Your handler should be safe to call multiple times with the same event.

  4. Use HTTPS — Webhook URLs must use HTTPS in production. HTTP is only allowed in the sandbox for local development.

  5. Monitor failures — Check the dashboard for failed deliveries. Persistent failures may indicate issues with your endpoint.

  6. Subscribe selectively — Only subscribe to the events you actually handle. This reduces unnecessary traffic and processing.

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