# Ingest API

> A summary of the server endpoints that send order, delivery and email events to Sayproof.

Source: /en/reference/ingest-api/

These endpoints are called server to server. For a detailed explanation and example code in several languages, see [Sending orders and deliveries](/en/integrate/orders.md) and [Email delivery events](/en/integrate/email-events.md).

## Authentication

| Endpoint | Method |
|---|---|
| `/v1/ingest/orders`, `/v1/ingest/deliveries` | `Authorization: Bearer <server-key>`; the key needs the send orders (`orders`) permission. Optionally, a signature with a signing password can be used instead. |
| `/v1/mail-events/generic` | A signature with the email delivery events password |

If the request has an `Authorization: Bearer` header, the server key is checked; otherwise the signature headers below are expected.

## Signature

| Header | Value |
|---|---|
| `X-Sayproof-Secret` | The password ID (the **Password ID** column of the password table in the panel; not the password's name) |
| `X-Sayproof-Timestamp` | An ISO 8601 time value including the time zone; it may differ from Sayproof's clock by at most ±5 minutes |
| `X-Sayproof-Signature` | `hex(HMAC-SHA256(password, time + "." + body))` |

The signature is calculated over the exact bytes sent.

## Endpoints

| Endpoint | Body | Response |
|---|---|---|
| `POST /v1/ingest/orders` | A single order or `{"channel", "orders": […]}` (up to 100 orders) | `{"accepted", "orders": [{"external_id", "id"}]}` |
| `POST /v1/ingest/deliveries` | A single delivery or `{"deliveries": […]}` (up to 100 records) | `{"accepted", "unknown": [{"external_id", "line"}]}` |
| `POST /v1/mail-events/generic` | A single event or `{"events": […]}` (up to 500 events) | `{"accepted", "matched", "duplicates", "ignored", "opted_out"}` |

On the endpoints specific to email services (`/v1/mail-events/ses`, `sendgrid`, `postmark`, `mailgun`), the address contains an access code specific to your brand instead of a signature; these addresses are shown in the panel.

## Behaviour

- **The order list is processed as a whole.** If one of the orders is refused, none of the other orders in the same request are saved.
- **`channel` can be given at the top of the body, in each order, or both**; if both are given, the value in the order wins. The order's market must match the channel's market; otherwise the request receives `400 invalid / market_channel_mismatch: <market>`.
- `external_id`, `line_reference`, `variant_key`, `group_key`, `catalog_id` and `customer_key` can be up to 128 characters.
- **When the same order is sent again, it is updated**; no duplicate is created. Order lines that are missing from a later submission are not deleted. If the customer details of an order have been turned into pseudonyms and the order is sent again, `customer_email`, `customer_phone` and `customer_key` are not written back.
- **Unknown lines in the delivery list do not cause the request to fail**; they are returned in the `unknown` list of the response. An unknown state value, however, causes the entire request to be rolled back.
- **The same delivery information** (the same line, state, source and time) is not saved a second time.
- **An estimated `delivered` report** does not decide the line's state and does not hide confirmed delivery information.
- **If a returned or cancelled line** arrives again as `delivered` in the order data without `delivered_at`, that state is ignored.
- **When the payment changes to `refunded` or `voided`**, or a line is reported as `returned`, product reviews written earlier receive the "Returned after the review" mark on their proof and the automatic checks run again; process experience reviews do not receive this mark.
- **The same email event** (the same `event_id`) is not processed a second time.

## Value lists

| Field | Values |
|---|---|
| `payment_state` | `pending`, `paid`, `partially_refunded`, `refunded`, `voided` |
| `fulfilment_state`, `state` | `placed`, `preparing`, `shipped`, `in_transit`, `delivered`, `cancelled`, `returned` |
| `source` | `platform`, `carrier`, `manual`, `estimated` |
| `confidence` | `verified`, `estimated` |
| Email event `type` | `delivered`, `deferred`, `bounced`, `complained`, `opened`, `unsubscribed` |

For error codes, see [Error codes › Transfer](/en/reference/errors.md#transfer).

## Example: a shared request function

The order and delivery endpoints are called with the server key, and email events are signed with the email delivery events password. The helper below covers both: given a server key it adds the `Authorization` header, given a password it adds the signature headers.

```js
import { createHmac } from "node:crypto";

export async function sayproofPost(path, payload, auth) {
  const body = JSON.stringify(payload);
  const headers = { "Content-Type": "application/json" };
  if (auth.serverKey) {
    headers.Authorization = `Bearer ${auth.serverKey}`;
  } else {
    const timestamp = new Date().toISOString();
    headers["X-Sayproof-Secret"] = auth.secretId;
    headers["X-Sayproof-Timestamp"] = timestamp;
    headers["X-Sayproof-Signature"] = createHmac("sha256", auth.secret).update(`${timestamp}.${body}`).digest("hex");
  }
  const answer = await fetch(`${process.env.SAYPROOF_BASE}${path}`, {
    method: "POST",
    headers,
    body,
    signal: AbortSignal.timeout(10000),
  });
  const result = await answer.json().catch(() => ({}));
  if (!answer.ok) throw new Error(`${answer.status} ${result.error ?? ""} ${result.detail ?? ""}`.trim());
  return result;
}

await sayproofPost(
  "/v1/ingest/deliveries",
  {
    deliveries: [
      { external_id: "ORD-1001", line_reference: "1", state: "delivered", observed_at: "2026-09-27T09:00:00+03:00", source: "carrier" },
    ],
  },
  { serverKey: process.env.SAYPROOF_SERVER_KEY }
);
```
