# Sending orders and deliveries

> Sending order and delivery data to Sayproof with the server key.

Source: /en/integrate/orders/

Sayproof creates the right to write a review, and the invitations, on the basis of orders. Your store system therefore needs to send orders and deliveries to Sayproof. The transfer is server to server, and every request is sent with the **server key**; this verifies that the data really comes from you.

:::note
If your store runs on Akinon, Shopify or WooCommerce, you do not need to write code: Sayproof reads the orders from the platform itself. See [Akinon orders](/en/integrate/akinon.md), [Shopify orders](/en/integrate/shopify.md), [WooCommerce orders](/en/integrate/woocommerce.md). On other platforms, orders are transferred through the endpoints on this page; a small integration service that forwards your store platform's order events (order created, paid, shipped, delivered, returned) to these endpoints is sufficient.
:::

## When to send

- When an order is created and whenever its payment state changes (paid, refunded, voided).
- When an order line is shipped, delivered, returned or cancelled.

Sending the same order again is safe: the order is updated and no duplicate is created. The simplest approach is to send the whole order again on every change.

For an invitation to be sent and a review to be written, the order line must be **delivered** and the order must be **paid**. After delivery, Sayproof sends the invitation once the waiting time on the **Invitation sending** screen (2 days by default) has passed.

## Endpoints

| Endpoint | Purpose |
|---|---|
| `POST /v1/ingest/orders` | Creates or updates orders and their lines |
| `POST /v1/ingest/deliveries` | Reports the delivery state of order lines |

The addresses are shown in the **Orders** and **Deliveries** rows of the **Values for the setup** list on the **Send orders and deliveries** card on the **Integration › Order transfer** tab in the panel, in the **Sent by your store** section; the same list also has the **Channel code**.

## Server key

Every request carries the server key in the `Authorization` header:

| Header | Value |
|---|---|
| `Authorization` | `Bearer <server-key>` |
| `Content-Type` | `application/json` |

The rules:

- The server key is created on the **Server keys** card on the **Channels › Keys** tab or in the **Keys** step of the channel setup wizard. It can be the same key you use for [SSR](/en/integrate/server-rendering.md).
- The key needs the send orders (`orders`) permission. Server keys created in the panel carry this permission automatically.
- The key is kept on the server only; it never goes into the browser or the page code.
- The order's channel is taken from the `channel` field in the body.

### Node.js

```js
export async function sendOrders(orders) {
  const answer = await fetch(`${process.env.SAYPROOF_BASE}/v1/ingest/orders`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.SAYPROOF_SERVER_KEY}`,
    },
    body: JSON.stringify({ channel: "web", orders }),
  });
  if (!answer.ok) throw new Error(`sayproof_ingest_${answer.status}: ${await answer.text()}`);
  return answer.json();
}
```

### Python

```python
import json
import os

import requests


def send_orders(orders):
    body = json.dumps({"channel": "web", "orders": orders}, ensure_ascii=False).encode("utf-8")
    answer = requests.post(
        f"{os.environ['SAYPROOF_BASE']}/v1/ingest/orders",
        data=body,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {os.environ['SAYPROOF_SERVER_KEY']}",
        },
        timeout=10,
    )
    answer.raise_for_status()
    return answer.json()
```

### PHP

```php
function sayproof_send_orders(array $orders): array
{
    $body = json_encode(['channel' => 'web', 'orders' => $orders], JSON_UNESCAPED_UNICODE);
    $curl = curl_init(getenv('SAYPROOF_BASE') . '/v1/ingest/orders');
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . getenv('SAYPROOF_SERVER_KEY'),
        ],
    ]);
    $answer = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    curl_close($curl);
    if ($answer === false || $status !== 200) {
        throw new RuntimeException('sayproof_ingest_' . $status);
    }
    return json_decode($answer, true);
}
```

### Command line

```bash
curl -X POST "$SAYPROOF_BASE/v1/ingest/orders" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $SAYPROOF_SERVER_KEY" \
  --data '{"channel":"web","orders":[]}'
```

## Signature

Signed sending is optional. For teams that want to sign their requests with a shared password instead of the server key, a **signing password** is created in the **Advanced: signing passwords** section at the bottom of the **Channels › Keys** tab. If the request has an `Authorization: Bearer` header, Sayproof checks the server key; otherwise it checks the signature. Every signed request carries three headers:

| Header | Value |
|---|---|
| `X-Sayproof-Secret` | The **ID** of the signing password: the **Password ID** column of the **Signing passwords** table. The password's name is not sent. |
| `X-Sayproof-Timestamp` | The time of the request in ISO 8601 format, including the time zone: `2026-09-28T07:30:00Z` or `2026-09-28T10:30:00+03:00` |
| `X-Sayproof-Signature` | `hex(HMAC-SHA256(password, time + "." + body))` |

The rules:

- The signature is calculated over **the exact bytes** sent. Do not reformat the JSON or add whitespace after signing.
- The request time may differ from Sayproof's clock by at most 5 minutes; your server's clock must be synchronised.
- Time values without a time zone (`2026-09-28T10:30:00`) are refused.
- The password itself is never sent in a request; it is only used to calculate the signature.

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

export async function sendOrdersSigned(orders) {
  const body = JSON.stringify({ channel: "web", orders });
  const timestamp = new Date().toISOString();
  const signature = createHmac("sha256", process.env.SAYPROOF_INGEST_SECRET)
    .update(`${timestamp}.${body}`)
    .digest("hex");
  const answer = await fetch(`${process.env.SAYPROOF_BASE}/v1/ingest/orders`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Sayproof-Secret": process.env.SAYPROOF_INGEST_SECRET_ID,
      "X-Sayproof-Timestamp": timestamp,
      "X-Sayproof-Signature": signature,
    },
    body,
  });
  if (!answer.ok) throw new Error(`sayproof_ingest_${answer.status}: ${await answer.text()}`);
  return answer.json();
}
```

## Order data

A single order or a list of up to 100 orders can be sent:

```json
{
  "channel": "web",
  "orders": [
    {
      "external_id": "ORD-1001",
      "payment_state": "paid",
      "market": "TR",
      "language": "en",
      "placed_at": "2026-09-25T10:00:00+03:00",
      "customer_email": "jane@example.com",
      "customer_key": "customer-4821",
      "items": [
        {
          "line_reference": "1",
          "variant_key": "PRODUCT-38",
          "group_key": "PRODUCT",
          "title": "Running shoe",
          "quantity": 1,
          "fulfilment_state": "delivered",
          "delivered_at": "2026-09-27T09:00:00+03:00"
        }
      ]
    }
  ]
}
```

| Field | Required | Description |
|---|---|---|
| `channel` | No | The channel code (up to 32 characters). Given at the top of the body, it applies to every order in the request; given inside an order, it applies only to that order; if both are given, the value in the order wins. If neither is given, the order's existing channel is kept. |
| `external_id` | Yes | The order number (up to 128 characters). This is the number the customer enters in the order check; it is case-sensitive. |
| `payment_state` | Yes | `pending`, `paid`, `partially_refunded`, `refunded`, `voided`. Reviews and invitations require `paid` or `partially_refunded`. |
| `market` | No | The market code, for example `TR`. If omitted, the channel's market is used. If a market other than the channel's market is sent, the order is refused (`market_channel_mismatch: <market>`). |
| `language` | No | `tr` or `en`. If omitted, the channel's language is used. |
| `placed_at` | Yes | The order time, including the time zone. |
| `customer_email` | No | The email address on the order (up to 254 characters). The invitation is sent to this address, and it is used in the order check and the signed-in customer query. If no address is sent, no invitation is sent. |
| `customer_key` | No | The customer's ID in your system (up to 128 characters). The daily invitation limit and the burst check are applied per this ID, or per the email address if there is no ID. |
| `customer_phone` | No | The phone number on the order (up to 32 characters); the field is accepted. The number is for [invitations on WhatsApp](/en/features/invitations.md#invitations-on-whatsapp). Until invitations by WhatsApp arrive with v2, the phone number is not stored. From then on it will be stored in international form (`+905321112233`); a number without a country code will be completed from the channel's market (in the Turkey market `0532 111 22 33` → `+905321112233`), and a number that cannot be completed will not be stored. |
| `items[]` | Yes | The order lines. |

The email address, phone and customer number on an order are turned into pseudonyms when the review writing period ends or when the customer files a personal data deletion request; if the same order is sent again later, these fields are not written back and the order is saved to the same record. For details, see [Retention periods](/en/security/data-protection.md#retention-periods).

Order line fields:

| Field | Required | Description |
|---|---|---|
| `line_reference` | Yes | The line's ID within the order (up to 128 characters). One review can be written for each line. |
| `variant_key` | Yes | The product code (up to 128 characters). It must be **the same** as the `data-review-product` value of the container on the page. |
| `group_key` | No | The product group code (up to 128 characters). Required for group-level review display. |
| `catalog_id` | No | The product's ID in the catalogue (up to 128 characters). |
| `title` | No | The product name (up to 300 characters). Shown in the review form. |
| `quantity` | No | The quantity; 1 by default. |
| `fulfilment_state` | No | `placed` (default), `preparing`, `shipped`, `in_transit`, `delivered`, `cancelled`, `returned`. |
| `delivered_at` | No | The delivery time. Recommended for lines in the `delivered` state. If a line that was returned or cancelled earlier arrives again in the `delivered` state without `delivered_at`, that state is ignored; if the line really was delivered again, send the delivery time. |

State fields only accept the values listed above; convert the state names in your own system to these values before sending.

For lines that arrive in the order data with the `delivered`, `returned` or `cancelled` state, Sayproof creates a delivery record; in that case you do not need to call the delivery endpoint separately.

Return and cancellation information also matters after a review. When the order's payment changes to `refunded` or `voided`, or a line is reported as `returned`, the proof of purchase of reviews written earlier for that order or line receives the "Returned after the review" mark and the automatic checks run again. Reviews written through a shopping experience invitation do not receive this mark, since they were written after the cancellation or return. For cancelled or returned lines that have not been reviewed yet, a shopping experience invitation is created; see [Invitations and emails](/en/features/invitations.md#process-invitation-after-a-cancellation-or-return).

### Response

```json
{ "accepted": 1, "orders": [{ "external_id": "ORD-1001", "id": "…" }] }
```

The list is processed **as a whole**: if one of the orders is refused, none of the other orders in the same request are saved. The code in the error response identifies the problematic field.

## Delivery data

For delivery changes that are not sent together with the order data:

```json
{
  "deliveries": [
    {
      "external_id": "ORD-1001",
      "line_reference": "1",
      "state": "delivered",
      "observed_at": "2026-09-27T09:00:00+03:00",
      "source": "carrier"
    }
  ]
}
```

| Field | Required | Description |
|---|---|---|
| `external_id`, `line_reference` | Yes | Which line of which order. |
| `state` | Yes | The same values as for an order line. |
| `observed_at` | Yes | The time the state was observed, including the time zone. |
| `source` | No | `platform` (default), `carrier`, `manual`, `estimated`. |
| `confidence` | No | `verified` (default) or `estimated`. |
| `detail` | No | Accepted but not stored; do not send details such as the recipient's name or address. |

Estimated information (an `estimated` source or confidence value) does not count as proof; an estimate such as "the carrier delivers in 3 days" does not create an invitation or a right to review. An estimated `delivered` report does not decide the line's state either: it is recorded, but it does not hide confirmed delivery information that arrived before or after it. Only send delivery information that has been confirmed.

Unknown lines do not cause the request to fail; they are returned in the `unknown` list of the response:

```json
{ "accepted": 1, "unknown": [{ "external_id": "ORD-9", "line": "2" }] }
```

## Errors

| Response | Meaning |
|---|---|
| `401 refused / unknown_key` | The server key is not recognised. For a malformed key the detail is `malformed_key`. Check that the whole key is sent after the `Bearer ` prefix. |
| `401 refused / revoked_key` | The server key has been cancelled. Create a new server key. |
| `403 refused / scope_missing` | The key does not have the send orders (`orders`) permission. |
| `401 signature_refused / signature_mismatch` | With signed sending, the signature does not match. Check whether the body changed after it was signed and whether the correct password was used. |
| `401 signature_refused / timestamp_outside_window` | The time difference is more than 5 minutes. Synchronise the server clock. |
| `401 signature_refused / timestamp_invalid` | The time value could not be read or contains no time zone. |
| `401 signature_refused / secret_unknown` | The password ID is not recognised or the password has been cancelled. Special-purpose passwords (notification address, email delivery events) cannot sign order transfers. This response is also returned when the request has no `Authorization: Bearer` header, because it is then treated as signed sending. |
| `400 invalid / field_missing: <field>` | A required field is missing. `field_invalid` and `field_too_long` report format and length errors. |
| `400 invalid / market_without_policy: <market>` | No rules are defined for this market. |
| `400 invalid / status_unmapped: …/<value>` | The state value is not recognised. Send one of the values listed above. |
| `400 invalid / channel_unknown` | The channel code is wrong or the channel is off. |
| `400 invalid / market_channel_mismatch: <market>` | The order's market is not the market of the channel the order is linked to. |
| `400 invalid / batch_too_large: 100` | More than 100 orders were sent in a single request. |
| `429 rate_limited` | The limit of 600 requests in 5 minutes per server key or password has been exceeded. |

## Connection test

The **Test the connection** action in the **Sent by your store** section of the **Integration › Order transfer** tab in the panel tests the data your store system will send without saving it. The form only asks **What will be sent?** (**Order** or **Delivery**) and the **Body to send**; no key or password is needed. The result shows whether the data was accepted and how many records would be accepted; nothing is saved.

On the same card, the **Latest orders received** line counts the orders received for the chosen channel. The request figures for the last 7 days and the **Recent requests** list in the **Order transfer status (all channels)** section cover all channels.
