Sending orders and deliveries
MarkdownSending order and delivery data to Sayproof with the server key.
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.
When to send
Section titled “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
Section titled “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
Section titled “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.
- 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
channelfield in the body.
Node.js
Section titled “Node.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
Section titled “Python”import jsonimport 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()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
Section titled “Command line”curl -X POST "$SAYPROOF_BASE/v1/ingest/orders" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $SAYPROOF_SERVER_KEY" \ --data '{"channel":"web","orders":[]}'Signature
Section titled “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.
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
Section titled “Order data”A single order or a list of up to 100 orders can be sent:
{ "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. 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.
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.
Response
Section titled “Response”{ "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
Section titled “Delivery data”For delivery changes that are not sent together with the order data:
{ "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:
{ "accepted": 1, "unknown": [{ "external_id": "ORD-9", "line": "2" }] }Errors
Section titled “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
Section titled “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.