> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sync2books.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Result callbacks

> Get the KRA outcome of a sale or credit note sent to your server, with the rules for the URL, the request we send, retries and how to build a safe receiver.

A sale usually comes back with its result straight away. Sometimes KRA is slow, or the sale has to be retried, and
the result arrives later. A **callback** tells your server when that happens, so you do not have to poll.

<Warning>
  **Callbacks are not signed.** There is no signature header and no shared secret, so you cannot prove from the
  request alone that it came from us. Treat a callback as a notification only: before you act on it, for example
  by releasing goods or marking an order paid, fetch the sale with `GET /v1/sales/{id}` and trust that.
</Warning>

## Ask for a callback

Add `callbackUrl` to `POST /v1/sales` or `POST /v1/credit-notes`.

```json theme={null}
{
  "traderInvoiceNumber": "INV-2026-0001",
  "saleDate": "2026-10-02",
  "lines": [{ "itemId": "{id}", "quantity": 2, "unitPrice": 3500 }],
  "callbackUrl": "https://example.com/hooks/sync2books"
}
```

The URL must:

* be an absolute **`https`** URL, at most 2048 characters;
* not contain credentials, as in `https://user:pass@host`;
* point at a **public** host. Localhost and private, loopback and link-local addresses are refused. The host is
  checked again at delivery against every address it resolves to.

A URL that breaks a rule is refused with a `400` that names `callbackUrl`. To test locally, expose your machine
with a public `https` tunnel, because `localhost` is refused.

## When we call you

| Event | Sent when |
| - | - |
| `sale.completed` | KRA accepted the sale or credit note. |
| `sale.failed` | KRA rejected it, or it failed. |

An event is sent for **every** result the document reaches, however it got there: the original request,
`POST /v1/sales/{id}/retry`, or a retry from the dashboard. If KRA rejects a sale, you fix it and it is then
accepted, you receive `sale.failed` and then `sale.completed`.

## What we send

A `POST` with a JSON body and these headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Sync2Books-Compliance-Callbacks/1.0` |
| `X-Callback-Id` | Identifies the result being delivered. The same for every retry of that result. |
| `X-Callback-Event` | `sale.completed` or `sale.failed` |
| `X-Callback-Attempt` | `1` for the first try, then `2`, and so on |

```json theme={null}
{
  "id": "sale_example_1:ACCEPTED:1",
  "event": "sale.completed",
  "occurredAt": "2026-10-02T10:15:31.000Z",
  "data": {
    "sale": {
      "id": "sale_example_1",
      "type": "sale",
      "status": "completed",
      "traderInvoiceNumber": "INV-2026-0001",
      "totals": { "total": 7000, "taxableAmount": 6034.48, "taxAmount": 965.52 },
      "receipt": { "cuInvoiceNo": "KRACU0000000000/12 NS", "signature": "AAAA-BBBB-CCCC" }
    }
  }
}
```

`data.sale` is the same object `GET /v1/sales/{id}` returns, without its `callback` block. It is abridged above.

## What your server must do

Reply with any **`2xx`** status within **10 seconds**. Anything else counts as a failed attempt: a non-`2xx`
status, a timeout, a connection error, or a redirect. We do not follow redirects.

## Retries

If an attempt fails we try again, up to **7 attempts in total**. The waits between attempts are 1, 5, 15, 60, 180
and 360 minutes, about ten and a half hours in all. Retries run on a sweep that happens once a minute, so the
timings are approximate. After the last failure the delivery is marked `failed`.

The same result is delivered once. If it is announced twice, you still get one delivery.

## Check the delivery state

`GET /v1/sales/{id}` includes a `callback` object that shows whether you were told:

| Field | Meaning |
| - | - |
| `status` | `awaiting_outcome` (no result yet), `pending` (waiting to send or between retries), `delivered` or `failed` |
| `event` | The event being delivered, or `null` before a result |
| `attempts` | How many attempts have been made for the latest result |
| `lastAttemptAt`, `lastResponseStatus`, `lastError` | How the last attempt went |
| `deliveredAt` | When your server answered `2xx` |
| `nextAttemptAt` | When the next retry is due, while `pending` |

## Resend a callback

If your endpoint was down for longer than the retries lasted, call `POST /v1/sales/{id}/callback/resend`. It needs
`sales:write`. It makes **one** attempt right now to deliver the latest result, and returns the updated `callback`
object so you can see how it went.

It is a `404` if the sale was created without a `callbackUrl`, and a `409` if the sale has no result from KRA yet.

## Build a safe receiver

<Steps>
  <Step title="Answer fast">
    Return `200` as soon as you have stored the event. Do the real work afterwards.
  </Step>

  <Step title="Ignore duplicates">
    Keep the `X-Callback-Id` values you have handled. It is stable across retries of one result, so a repeat is
    safe to skip.
  </Step>

  <Step title="Confirm before acting">
    Fetch `GET /v1/sales/{id}` with your key and use that response, not the callback body, to decide what to do.
  </Step>
</Steps>

```javascript Node.js theme={null}
import express from "express";

const app = express();
app.use(express.json());

const seen = new Set(); // use a database in production

app.post("/hooks/sync2books", (req, res) => {
  const callbackId = req.get("X-Callback-Id");
  res.sendStatus(200); // answer first

  if (seen.has(callbackId)) return;
  seen.add(callbackId);

  // Do not trust the body. Re-fetch the sale and act on that.
  fetch(`${process.env.S2B_URL}/sales/${req.body.data.sale.id}`, {
    headers: { "x-api-key": process.env.S2B_KEY },
  })
    .then((r) => r.json())
    .then(({ data }) => {
      if (data.sale.status === "completed") {
        // mark the order as fiscalised
      }
    });
});

app.listen(3000);
```

## Read next

* [Sales](/etims-guide-sales): issue a sale with a `callbackUrl`
* [Idempotency](/etims-idempotency): repeating a request safely
* [Errors & Troubleshooting](/etims-errors): what a `422` means


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.