Qoyod
Pricing

Knowledge Base

Webhooks in E-Invoicing: A Developer’s Guide

When you build an integration with the Fatoora platform to issue and clear e-invoices, the APIs handle each invoice through an immediate request and response. But the real integration does not live inside that request alone. Many events unfold around it: a middleware platform finishes processing a batch of invoices, Qoyod completes linking a certificate, a deferred reporting operation reaches its result after seconds or minutes. How does your system learn about these events without polling the server every second? The answer is Webhooks.

This guide explains what a Webhook is, when it is the right integration tool and when it is not, how to receive asynchronous status callbacks safely, how to verify the message signature, how to handle duplicate delivery idempotently, and how to deal with retries from the sender. The goal is for the developer to walk away with a practical model that holds up in production, built on a precise understanding of what is synchronous and what is asynchronous in e-invoicing.

This article is part of the developer hub within E-Invoicing by Qoyod. It assumes you have read Fatoora platform API endpoints and know the difference between the clearance interface and the reporting interface. This guide also complements what was covered in API error handling andretry logic, because receiving events over a Webhook is governed by the same error and retry rules but from the opposite side.

What a Webhook is and why we need it

A Webhook is an HTTP call that a source system sends to a URL you own, the moment an event you care about occurs. Instead of your system continuously asking the server «has the operation completed?», the direction flips: the server is the one that knocks on your door once the result is ready. That is why a Webhook is sometimes described as a «reverse API», because here you are the server that receives, not the client that requests.

The essential difference between the two patterns comes down to who initiates the connection. In polling, your system initiates the request at fixed intervals, wasting many empty requests and waiting for the result with a delay that can reach the full interval. In a Webhook, the source initiates the connection the instant the event occurs, so the result arrives almost immediately with no empty requests. This makes the Webhook the more suitable choice for asynchronous events whose exact timing you cannot know.

When a Webhook is the right tool in e-invoicing

Here precision is required to avoid a common mistake. Clearance of B2B tax invoices on the Fatoora platform is a synchronous operation: you send the invoice in a single request and wait for the response on the same connection, so you know immediately whether it was accepted or rejected. Likewise, reporting of simplified B2C invoices happens through a request and response, even if it is near-instant rather than fully instant. That is, the authority’s result does not reach you over a Webhook, but in the direct response body of the request you sent. This point does not tolerate confusion.

So where does a Webhook fit? It fits in the layer surrounding the integration, not in the call to the authority itself. Practical examples:

  • Events emitted by Qoyod itself: «invoice issuance completed», «invoice clearance completed successfully», «invoice rejected and needs correction», «CSID certificate linking completed».
  • Events from a middleware platform that manages invoice batches on your behalf and notifies you of each batch’s result when its processing finishes.
  • Background operations on your side: such as a nightly sync that processes thousands of invoices, then informs the accounting system of its completion over a Webhook instead of keeping it waiting.

In short: clearance and reporting at the authority are synchronous, and the Webhook serves the events that revolve around this operation inside integration platforms and Qoyod, not inside the direct call to the authority.

Polling versus Webhook: when to choose which

A Webhook is not an absolute replacement for polling. Each pattern has its place. Polling is simpler to build: your system requests the status at intervals, and needs neither a publicly exposed URL nor a signature-verification layer. Its drawback is that it wastes many empty requests and reaches you with a delay of one interval, and it may hit rate limits if you shorten the interval too much.

A Webhook is more efficient in timing and wastes less, since the result reaches you the moment it is ready with no empty requests. Its price is that it forces you to build a secure receiving endpoint, verify the signature, and handle duplication and retries. The practical rule: use a Webhook when latency matters to you and you deal with large volumes, and use polling for simple cases or as a safety net that catches any event you missed. Many mature systems combine both: the Webhook as a primary path, and light polling as a periodic reconciliation that ensures no update is lost.

Polling versus Webhook
Why a Webhook is more efficient than repeatedly asking the server.
Criterion Polling Webhook
Pattern Repeated requests, most of them empty A single event when the result is available
Efficiency Wastes resources Instant and economical
Timing Delayed by the polling interval Instant at the moment of the event
Fatoora’s clearance and reporting interfaces are synchronous; the Webhook is for surrounding integration events.

Webhook payload structure (Payload)

When the event occurs, the source sends an HTTP POST request to your URL, its body usually JSON describing the event. A good payload carries enough to tell you what happened, when, for which entity, and a unique identifier for the delivery itself. Below is a sample payload for an «invoice clearance completed» event as it might reach you from the integration layer:

POST /webhooks/qoyod HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
X-Qoyod-Event: invoice.cleared
X-Qoyod-Delivery: 8f3c1d2a-7b44-4e90-9a21-6c5e0f2b1d77
X-Qoyod-Signature: sha256=4a7d1ed414474e4033ac29ccb8653d9b...
X-Qoyod-Timestamp: 1718900000

{
  "id": "evt_01HZX9K2M7",
  "type": "invoice.cleared",
  "createdAt": "2026-06-20T10:13:20Z",
  "data": {
    "invoiceId": "inv_77219",
    "invoiceUuid": "3cf1f8a0-9d2b-4c11-bf3e-2a91d0c4e5b6",
    "status": "CLEARED",
    "clearedAt": "2026-06-20T10:13:19Z"
  }
}

The core fields you must handle carefully:

  • id or the header X-Qoyod-Delivery: a unique identifier for this specific delivery. It is your key to preventing duplicate processing, as we will see later.
  • type: the event type (such as invoice.cleared or invoice.rejected). Based on it you route the processing.
  • createdAt andX-Qoyod-Timestamp: the time the event occurred, useful for ordering and for rejecting overly old messages.
  • X-Qoyod-Signature: a digital signature you verify before trusting the payload.
  • data: the event details, and here the invoice identifier and its status.

Note that the payload does not carry the authority’s decisions directly. It describes the result of an operation already completed through an earlier synchronous call. That is, the Webhook here informs your system of what has completed, it does not ask you to call the authority.

Receiving the event safely: verifying the signature

Your Webhook URL is exposed on the internet. Any party that knows the URL can send a forged POST request claiming that an invoice was accepted or rejected. So the first rule: do not trust any payload before you prove it truly originated from the declared source. The usual means of proof is an HMAC signature.

The principle is simple. The source and your system share a single secret (signing secret). The source computes an HMAC-SHA256 digest over the raw request body using this secret, and places it in a header X-Qoyod-Signature. On receipt, you compute the same digest over the raw body with the same secret, then compare. If the two digests match, the message is authentic and was not altered.

const crypto = require("crypto");

function verifySignature(rawBody, signatureHeader, secret) {
  // Compute the expected digest over the raw body exactly as it arrived
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(rawBody, "utf8")
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || "");
  // Constant-time comparison to avoid leaking information through timing differences
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Three rules you must not compromise on when verifying:

  • Compute the digest over the raw body before any JSON parsing. Parsing the body then re-serializing it changes the bytes, so the match fails.
  • Compare the two digests with a constant-time comparison function such as timingSafeEqual, not with ordinary string equality, so you do not leak information through rejection-timing differences.
  • Inspect the timestamp in X-Qoyod-Timestamp, and reject messages whose age exceeds a reasonable window (five minutes, for example), so you thwart replay attacks with an old signed payload.

Add to that a simple and decisive condition: receive Webhooks over HTTPS only. A plain HTTP URL passes the signature and payload in cleartext over the network, making them easy to intercept and tamper with. Serious integration platforms refuse outright to deliver any Webhook to an unsecured URL.

Designing the receiving endpoint with a defensive mindset

Treat the Webhook URL like any public interface exposed to attack. Set an upper limit on the accepted body size so a malicious party cannot flood your memory with a huge payload. Do not assume the payload is structurally valid JSON; instead parse it inside an error-handling block, and if parsing fails return 400 without the request collapsing. Log for every delivery its unique identifier, its arrival time, and its processing result, because this log is your first tool when you diagnose a problem later.

Also separate two responsibilities: the first is to receive, verify, and store the request quickly, and the second is to apply the business logic to it. This separation protects you from tying the stability of the receiving endpoint to the speed of your database or a slow external service. The lighter and faster the receiving endpoint, the lower the chance of exceeding the sender’s timeout and the retries and duplication that follow.

Receiving a Webhook event safely
How your system verifies the validity of the incoming event.
1

Receive POST over HTTPS

2

Verify HMAC signature

3

Verify the timestamp window

4

Reply 200 then process

Any verification failure rejects the event (401/400) before processing.

The correct reply to the sender and its effect on retries

The source judges delivery success from the HTTP status code you return. If you return a code within the range 2xx consider the delivery successful and stop. If you return 5xx or the timeout expires with no reply, consider the delivery failed and retry later. This rule ties this guide directly toretry logic, but from the sender’s side rather than the recipient’s.

From here springs an important design rule: return 200 quickly, then process the event in the background. If you wait until you finish heavy processing (an accounting sync, a database update, sending notifications) before you reply, you may exceed the source’s timeout so it assumes the delivery failed and resends it, and you process the event twice. The safer approach is to verify the signature, store the event in an internal queue, return 200 immediately, then process it with a separate worker.

When should you return an error code deliberately? Return 401 when signature verification fails, and400 when the payload is malformed or the timestamp is outside the window. But when you receive a valid event yet your internal system temporarily fails to process it, return 5xx so the source retries instead of dropping the event silently.

Idempotent processing: handling duplicate delivery

The most important fact in the world of Webhooks: delivery is «at least once», not «exactly once». Meaning the source guarantees the event reaches you, but does not guarantee it will not reach you more than once. The same event may reach you twice because your first reply was late, or because the network cut off the reply after you had actually processed the event, so the source retried thinking the delivery had failed.

Therefore your processing must be idempotent: executing the same event twice yields the same result as if it were executed once. Otherwise you may record the invoice twice, or send two notifications, or update a balance twice. The key is the unique delivery identifier id or X-Qoyod-Delivery.

async function handleEvent(event) {
  // Record the unique identifier first. A unique column in the database prevents duplication.
  try {
    await db.insert("processed_events", { event_id: event.id });
  } catch (err) {
    if (err.code === "UNIQUE_VIOLATION") {
      // This event arrived and was processed before. Ignore it quietly and return 200.
      return { status: 200, note: "duplicate_ignored" };
    }
    throw err;
  }

  // The first time we see this event: process it for real
  await applyBusinessLogic(event);
  return { status: 200 };
}

Practical rule: make the event_id column a unique key in the processed-events table. The first insert succeeds so you process the event, and the second insert fails on the unique-constraint violation so you ignore the duplicate quietly and return 200. This way duplication becomes structurally harmless, not merely a matter of hope.

Note a subtle distinction: do not confuse «the event» with «the entity». The event identifier event.id belongs to this delivery, whereas the invoice identifier invoiceUuid belongs to the entity itself. You may receive different events about the same invoice (issued, then accepted), so do not reject them on the grounds that they are for the same invoice. Idempotency rests on the unique event identifier, not on the entity identifier.

Idempotency
How you handle a repeated delivery of the same event.
1

Event with event_id

2

Duplicate delivery of the same identifier

3

Unique key: ignore and return 200

At-least-once delivery requires idempotent processing to prevent duplication.

A full scenario: from the event occurring to updating your system

Let us combine the layers into a single path. A client issues a B2B invoice through an integration platform. The platform calls the clearance interface at the authority in a synchronous request, and the response comes back that the invoice was accepted. Up to here there is no Webhook in the picture, since the result arrived in the direct response body.

After that the platform wants to inform your accounting system that clearance was done so you update the invoice status on your side. Here the Webhook enters. The platform sends a POST request to your URL with an event invoice.cleared signed with HMAC. Your system verifies the signature, checks the timestamp, records the unique event identifier, returns 200 immediately, then updates the invoice status to «accepted» in the background.

Suppose your first reply was late due to a momentary load, so the platform resent the same event. This time inserting the event identifier fails on the unique-constraint violation, so your system ignores the duplicate and returns 200 without updating the status a second time. The result: one correct invoice status, no duplication, despite the event arriving twice. This is the integration that holds up in production.

Event ordering: do not assume sequential arrival

The events of a single invoice may reach you in an order different from the order in which they occurred. For example, you receive the «status changed to accepted» event before the «invoice issued» event, because the network and retries do not preserve the sequence. If you build your logic on the assumption of ordered arrival, you may overwrite a newer status with an old one.

The remedy is to rely on the timestamp inside the payload, not on the arrival order. Store with each invoice the time of the last event you applied, then ignore any event older than it. This way your system stays consistent even if the events arrive scattered. The rule: order by content (createdAt) not by receipt time.

async function applyIfNewer(invoiceId, event) {
  const current = await db.get("invoices", invoiceId);
  // Apply the event only if it is newer than the last recorded status
  if (current && current.lastEventAt >= event.createdAt) {
    return; // Stale event, ignore it to preserve consistency
  }
  await db.update("invoices", invoiceId, {
    status: event.data.status,
    lastEventAt: event.createdAt
  });
}

Retries from the sender’s side and the dead-letter queue

When you return 5xx or your timeout expires, the sender does not give up after the first attempt. It redelivers at increasing intervals following exponential backoff: after seconds, then minutes, then hours, up to a maximum number of attempts. This gives your system a chance to recover from a momentary fault without dropping the event. It is the same rule explained in retry logic, but executed on the Webhook sender’s side this time.

What happens if your system stays down until all attempts are exhausted? Here the dead-letter queue enters. A serious sender keeps the events it could not deliver at all in a separate queue, and lets you review them and resend them manually after fixing the fault. And from your side too, it is wise to keep a copy of every event you receive before processing it, so even if the internal processing fails you can replay it later without waiting for the source to resend.

The core idea is that no event is lost silently. Either it arrives and is processed, or it is redelivered, or it is stored in a queue for review. The worst case is an event that vanished without a trace, because it leaves an invoice with a stale status that no one discovers until too late.

Managing and rotating the secret

The signing secret is the cornerstone of Webhook security. Treat it as you would treat a sensitive password. Do not put it in the source code nor in the git repository, but in environment variables or a secrets vault. And do not print it in your logs.

From time to time, or when you suspect it has leaked, rotate the secret. Safe rotation supports two valid secrets during a transition period: the old and new secret together. You verify the signature against either one, and if either matches you accept the message. After the source stops using the old secret, you delete it. This way you rotate without dropping a single message during the transition.

  • Store the secret outside the code in an environment variable or a secrets vault.
  • Support two secrets during the rotation window, then delete the old one.
  • Do not print the secret nor the full signature in the logs.
  • Rotate immediately on any suspicion of a leak.

Checklist for a sound Webhook integration

  • The URL receives over HTTPS exclusively, not HTTP.
  • You verify the HMAC signature over the raw body with a constant-time comparison.
  • You inspect the timestamp and reject stale messages outside the window.
  • You return 200 quickly then process the event in the background.
  • You return 5xx on a temporary internal failure so delivery is retried.
  • Your processing is idempotent via the unique event identifier, so duplication is harmless.
  • You order events by the timestamp inside the payload, not by arrival time.
  • You store every event before processing it so you can replay it.
  • The secret is stored securely and you support rotating it without losing messages.
  • Do not assume the Webhook carries the authority’s direct result.

Common mistakes in Webhook integration

  • Trusting the payload without verifying the signature. The most dangerous mistake. It lets any party inject forged events. HMAC verification is non-negotiable.
  • Assuming exactly-once delivery. Leads to duplicate recording. Make the processing idempotent via the unique event identifier.
  • Heavy processing before replying. Causes a timeout, then resending, then duplication. Return 200 quickly and process later.
  • Confusing the Webhook with the call to the authority. Expect the clearance or reporting result in the direct response body, not over a Webhook. The authority does not deliver its results to you this way.
  • Returning 200 on a temporary internal failure. Drops the event silently. Return 5xx so the source retries.
  • Accepting unsecured HTTP. Exposes the signature and payload. Receive over HTTPS exclusively.
  • Neglecting the timestamp window. Opens the door to replay attacks with an old signed payload. Reject stale messages.

How Qoyod helps you at the Webhook layer

Qoyod is a cloud accounting software compliant with e-invoicing and officially approved by the Zakat, Tax and Customs Authority. Qoyod manages the clearance and reporting connection with the Fatoora platform, manages the CSID certificate automatically, and generates the e-invoice in its approved format. That is, a client who issues invoices through Qoyod gets the clearance and reporting result ready without building this layer themselves.

For the developer who connects an external system to a client’s account on Qoyod, the integration layer allows receiving status events through the mechanisms Qoyod’s interface provides, so their system stays aware of invoice status changes without polling. And whoever prefers not to build the event, verification, and idempotency layer themselves gets the processing ready through Qoyod.

It is worth recalling that Qoyod does not register the client with the authority on their behalf. Registering the CSID certificate with the authority is a step the client performs themselves, and Qoyod guides them through it. Likewise Qoyod does not file the VAT return on the client’s behalf, but generates the return data for the client to file through the authority’s portal.

Start today

E-invoicing ready without building the integration layer yourself

Qoyod manages clearance and reporting with the Fatoora platform, manages the CSID certificate automatically, and keeps your invoice status up to date. Start your free trial and leave the technical layer to Qoyod.

Start your free trial and issue your compliant invoices

Next steps in the developer hub

After mastering receiving events over Webhooks, the two directly complementary topics are error handling and retry logic. API error handling explains how to read HTTP status codes and the structure of error and warning messages coming from the Fatoora platform, which are the same rules you apply when you reply to the Webhook sender. Andretry logic explains when a retry is made and how to build exponential backoff with jitter, which is what the Webhook sender does when you return it a 5xx or your timeout expires.

For review, make sure you are familiar with the integration basics through Fatoora platform API endpoints, and with the invoice acceptance conditions through invoice validation rules. These guides together with this article form a solid foundation for any mature integration that receives e-invoicing events safely.

Practical summary

A Webhook is a reverse API that knocks on your door the moment an event occurs instead of you polling the server. In e-invoicing the authority’s decision does not reach you through it, because clearance and reporting happen through a synchronous request and response. The Webhook serves the surrounding events: issuance completion, clearance result, status change, batch completion at a middleware platform.

A mature Webhook integration rests on four pillars. It verifies the HMAC signature over the raw body with a constant-time comparison. It inspects the timestamp and rejects stale ones. It returns 200 quickly then processes in the background, and returns 5xx on a temporary failure so delivery is retried. And it makes the processing idempotent via the unique event identifier so duplication does not harm it. Whoever prefers not to build this layer themselves gets it ready through Qoyod.

Guides

Continue your learning journey

Explore the rest of Qoyod’s guides, or start applying what you’ve learned.

Live webinars hosted by the Qoyod team to help you use the software easily and answer your questions.

Discover Qoyod’s latest updates, ongoing improvements, and new features in one place.

Our team is ready to help you and provide instant support for any issue you face, around the clock.