Skip to content

Sending orders and deliveries

Markdown

Sending 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 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.

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.

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 channel field in the body.
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();
}
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()
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);
}
Terminal window
curl -X POST "$SAYPROOF_BASE/v1/ingest/orders" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SAYPROOF_SERVER_KEY" \
--data '{"channel":"web","orders":[]}'

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();
}

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.

{ "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.

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" }] }
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.

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.