Receive Inbound Invoice Webhooks
Configure the parent webhook, process inbound-invoice events idempotently, and reconcile delivery safely.
NRS Invoice Gateway sends one event type, invoice.inbound_received. Duplo sends it after it has received an inbound NRS invoice addressed to one of your represented businesses and stored that invoice, so the invoice is already readable through the Gateway API by the time the event reaches you.
Every event goes to a single URL saved on the API credential of your top-level business in Duplo Dashboard. There is no per-represented-business URL, so read gatewaySubBusinessId in each event to tell which represented business the invoice was addressed to.
What this webhook is, and is not
invoice.inbound_received reports an invoice that someone else issued to one
of your represented businesses. It is never sent for work you started: a
Pass-through sign, a Stored sign, a billing charge, and a transmission each
report their own result. Keep reading the synchronous response for
Pass-through calls, and keep polling GET /invoices/{invoiceId} for Stored invoices.
Delivery flow
Notify
NRS tells Duplo that an inbound invoice with a given IRN is available.
Store
Duplo resolves the test or live state and the represented business, downloads the invoice, and stores it.
Enqueue
Duplo records the event for delivery in the same transaction that stores the invoice, so no event is queued for an invoice you cannot yet read.
Deliver
Duplo POSTs the event to your URL. Because a delivery can be missed, your application also reconciles against the invoice API.
Building your webhook endpoint
Your webhook endpoint should be accessible on a dedicated public HTTPS URL, for example:
https://api.example.com/webhooks/duplo/invoice-gateway/<unguessable-route-secret>Your endpoint should:
- Accept
GETand return any 2xx response. Duplo sends aGETas its reachability check each time you save the URL in Duplo Dashboard. - Accept
POSTwith a JSON body for real deliveries. - Persist the delivery ID and event body before acknowledging the delivery.
- Return a 2xx response quickly, then do expensive work asynchronously.
- Be reachable over public DNS and a public IP. Duplo rejects private, loopback, link-local, and other internal addresses when you save or check the URL.
- Avoid HTTP redirects. The reachability check does not follow them, so a URL that redirects is recorded as unreachable.
Duplo allows only a short time for the reachability check and for each delivery. Do not call NRS, generate PDFs, send email, or run long database workflows before you return your 2xx.
Adding the webhook URL to your dashboard
In Duplo Dashboard, open Settings > Developer API, scroll to Webhook, enter your publicly accessible webhook URL, and click Save.

When you click Save, Duplo makes a GET request to the URL and waits for a 2xx response. It stores the URL only if that check succeeds.
The setting is stored on the credential for the state your business is currently in, test or live, so save and verify it again after you move from test to live. Webhook readiness is one section of the go-live checklist, which covers that move alongside the rest of your integration.
Parsing webhook events
The structure of an invoice.inbound_received event is below. The delivery ID arrives as a request header, and everything else as a JSON body:
{
"event": "invoice.inbound_received",
"invoiceId": "66666666-6666-4666-8666-666666666666",
"invoiceReferenceNumber": "INBOUND01-2A3A045D-20260818",
"gatewaySubBusinessId": "44444444-4444-4444-8444-444444444444",
"businessState": "test"
}Each field does a specific job in your receiver:
| Field | Use |
|---|---|
event | Dispatch on the event type. invoice.inbound_received is the only value today; store or ignore any other value rather than failing |
invoiceId | Read the stored invoice through the Gateway API and correlate later work |
invoiceReferenceNumber | Match the invoice against your own record of the IRN. Do not use it to decide which represented business the invoice belongs to |
gatewaySubBusinessId | Identify the represented business the invoice was addressed to |
businessState | Either test or live, always matching the credential this URL was saved on. Route the event to the matching queues, data stores, and downstream actions |
x-duplo-delivery-id | Deduplicate repeat deliveries of the same event |
Before enqueueing anything, check that the identifiers are well-formed UUIDs or strings, that event is exactly invoice.inbound_received, and that businessState is the state this receiver handles. Preserve JSON fields you do not recognize so later additions to the payload do not break your receiver.
An event always carries the state of the credential its URL was saved on, so a live endpoint never receives test events and a test endpoint never receives live ones. Check businessState anyway. The comparison is cheap, and it catches the configuration mistake this guarantee cannot prevent: a deployment holding the wrong environment's credentials.
Deduplicating and acknowledging deliveries
Parsing tells you what arrived. The next question is whether you have already seen it, because the same event can reach you more than once. Put a uniqueness constraint on the delivery ID, then handle each delivery in this order:
- Read
x-duplo-delivery-id. Return400if it is missing or malformed. - Validate the event shape, the event name, and
businessState. - In one transaction, insert
{deliveryId, event, invoiceId, receivedAt, rawBodyHash}into an inbox table whosedeliveryIdcolumn is unique. If the insert succeeds, write the queue or outbox record in that same transaction. If it fails as a duplicate, write nothing. - Return
204in both cases. A duplicate delivery is not an error. - Process the queued work after you have responded.
If you cannot persist a delivery, for example while your database is unavailable, return a non-2xx status. Never acknowledge an event you did not store.
const EXPECTED_BUSINESS_STATE = "test"; // the state this receiver handles
async function receiveDuploWebhook(request: Request): Promise<Response> {
const deliveryId = request.headers.get("x-duplo-delivery-id");
const event = await request.json();
if (
!deliveryId ||
event.event !== "invoice.inbound_received" ||
event.businessState !== EXPECTED_BUSINESS_STATE
) {
return new Response("invalid webhook", { status: 400 });
}
await database.transaction(async (tx) => {
const inserted = await tx.webhookInbox.insertOnce(deliveryId, event);
if (inserted) await tx.jobs.enqueue("handle-inbound-invoice", event);
});
return new Response(null, { status: 204 });
}This is illustrative pseudocode. Implement insertOnce with a real database uniqueness constraint on deliveryId, not an in-memory set, so the check survives a restart and holds across every instance of your service.
Verifying events
Verify an event by what it points to rather than by the request that carried it. The payload holds identifiers only, so an event is a signal that something changed, and the Gateway API is the source of truth about what changed. Read the invoice back with your API key, act on that response, and your integration is sound against anything an attacker can send to your URL: a forged event naming an invoice that does not exist returns nothing, and one naming a real invoice returns exactly what Duplo would have told you anyway.
Deliveries are not signed, so use x-duplo-delivery-id for what it is, a delivery identifier for deduplication rather than a proof of origin. The authenticated read is what establishes trust, and it does so for every event regardless of where the request came from. Read the authoritative invoice covers the call and the fields to match.
Build the endpoint around that pattern:
- Keep every irreversible step behind the authenticated read, including accounting entries, payments, and any document you pass to a represented client. No request arriving at your endpoint should be able to trigger one on its own.
- Serve the endpoint over TLS, and give its path a long random segment that you store as a secret alongside your API keys, so the URL is not something an outsider can guess or find in a log.
- Restrict by network only against egress ranges Duplo has given you for your environment, rather than addresses inferred from traffic you observe.
- Rotate the URL if it is ever exposed: save a new secret path in Duplo Dashboard, then stop serving the old one.
Read the authoritative invoice
An event tells you that an invoice exists. Read the invoice itself before acting on it:
curl \
--url https://acme.invoice.tryduplo.com/invoices/66666666-6666-4666-8666-666666666666 \
--header 'x-api-key: pk_test_replace_with_your_key'The route is scoped to your top-level business, so your x-api-key is the only header it needs. Do not send x-sub-business-id here, even though the invoice belongs to a represented business; that header is for the invoice operations you perform on behalf of one.
Before you create accounting entries or pass invoice content to a represented client, check that the response's invoice ID, invoiceReferenceNumber, gatewaySubBusinessId, and businessState match the event. If any of them differ, stop and investigate instead of processing the invoice.
Delivery retries
Duplo records the delivery intent before it makes any network call, and it retries failed deliveries internally with a dead-letter path for deliveries that keep failing. Retry counts and schedules are environment configuration rather than part of the customer contract, so treat redelivery as best effort: a delivery that fails may never arrive. Never build a flow in which an inbound invoice can only be discovered from a retried webhook.
What follows from that:
- The same event can arrive more than once, so your receiver must stay idempotent across deploys and database failovers.
- A 2xx response tells Duplo you have taken responsibility for the event. Duplo does not wait for your downstream workflow to finish.
- Reconcile inbound invoices on a schedule through the authenticated
GET /invoicesandGET /invoices/{invoiceId}routes on your Gateway host, even while webhook delivery looks healthy.
If your endpoint was unavailable, restore it, confirm reachability in Duplo Dashboard, and reconcile through those same two routes. Duplo exposes no customer-facing redrive or delivery-status endpoint today, so contact Duplo support if you need a specific delivery investigated.
How is this guide?
Last updated on