Send orders from your servers
Send your store's orders to Tapper from your own servers, with the click ID your site saved at landing, so each order can be tied to the ad click behind it.
If your store is custom-built, there is no store platform for Tapper to connect to. Instead, your servers send each order to a secure address that belongs to your protected entity. When an order carries the Google click ID your site saved when the visitor landed, that click ID is what lets Tapper tie the order to the ad click behind it, with no change in the browser.
What we never accept
Never send names, postal addresses, raw email addresses or raw phone numbers. Tapper checks every record for them, refuses a record where it finds one and says why in the response. The rest of the batch is still accepted.
Tapper looks for personal data in two ways, anywhere in a record:
- By field name. A field named like an email, phone, name or address (for example
email,phone,mobile,first_name,last_name,customer_name,address,shipping_address,street,cityorpostcode) is refused, whatever it holds. - By value. Any text that looks like an email address, a phone number or a street or building address is refused, whatever field it is in.
Every ID field refuses spaces, so a name cannot be sent as a customer ID. Country is all Tapper takes about where an order goes.
An email address or phone number may only be sent as a hash, made on your servers (see Hash email and phone). A refusal reason names the field and never repeats what you sent, for example:
customer.email is a raw email address, which Tapper does not take: send customer.email_sha256, the SHA-256 of the trimmed, lower-cased address, instead.
customer.first_name is a person's name, which Tapper does not take: send customer.customer_id, the store's own customer ID, instead.
payment_method looks like a raw phone number. Tapper does not take personal data: send customer.phone_sha256, the SHA-256 of the number in E.164 form, instead.The address and how to authenticate
Each protected entity has one order feed. To get one, ask your Tapper contact. When it is set up you get two things:
| What | Example |
|---|---|
| The address | https://, where <id> is your order feed's number |
| The secret | tof_ followed by 64 hexadecimal characters |
Send every batch as a POST with a JSON body, and the secret in the X-Tapper-Order-Feed-Secret header:
POST /order-feed/<id>/orders HTTP/1.1
Host: api.tapper.ai
Content-Type: application/json
X-Tapper-Order-Feed-Secret: tof_<your secret>- The header is the only place the secret is read. Not the address, not the body, not an
Authorizationheader. - Call it from your servers only. The secret is a password for writing orders into your Tapper data. It is not the public key in your monitoring script, and it must never reach a browser or an app.
- The secret is shown once. Tapper keeps only a salted hash of it and cannot show it again. Store it in your secrets manager.
- A new secret stops the old one at once. If the secret is lost or may have leaked, ask for a new one. Batches sent with the old secret get
401from the moment the new one is issued, so switch your servers over straight away.
A missing or wrong secret, or an address that does not exist, all get the same answer: 401 with a WWW-Authenticate: X-Tapper-Order-Feed-Secret realm="tapper-order-feed" header and this body.
{
"errors": [
{
"code": "unauthorized",
"message": "Send this order feed's secret in the X-Tapper-Order-Feed-Secret request header."
}
]
}What you send
The body has two fields:
| Field | Type | Required | What it is |
|---|---|---|---|
records | list | Yes | 1 to 500 records. |
test | true or false | No | true checks and echoes the records and stores nothing (see Test without storing anything). |
Each record is one of four events, named in its event field:
event | When to send it |
|---|---|
placed | An order was placed. |
updated | An order changed, for example its status or value. Send the whole order again. |
refund | A refund, full or partial. |
cancellation | An order was cancelled. |
Your order ID is the key. Sending the same order again, as placed or updated, updates the order Tapper holds instead of adding a second one. An updated record for an order Tapper does not hold yet adds it.
Each record is checked on its own. A record with a problem is refused with its reasons, up to 20, and the other records in the batch are still accepted.
Field table
Field names are exact and in snake_case. A field this table does not list is refused, not ignored.
Every placed and updated record
| Field | Type | Required | Notes |
|---|---|---|---|
event | placed or updated | Yes | |
order_id | text or whole number | Yes | Your order ID. Up to 255 characters, no spaces. A number is kept as its digits; send IDs longer than 15 digits as text. |
placed_at | date and time | Yes | ISO 8601 with Z or an offset, for example 2026-10-15T09:30:00Z. |
currency | text | Yes | Three-letter ISO 4217 code, for example AED. |
value | number | Yes | The order's total value, 0 or more. |
status | text | Yes | Your own status, for example paid. Up to 64 characters. |
updated_at | date and time | No | When your store last changed the order. |
About the order (all optional)
| Field | Type | Notes |
|---|---|---|
value_ex_tax_shipping | number | The order value before tax and shipping. |
discount | number | The total discount. |
coupon_codes | list of text | Up to 20 codes, each up to 64 characters. |
payment_method | text | Up to 64 characters, for example card. |
sales_channel | web or app | |
country | text | Two-letter ISO 3166 code, for example AE. |
site | text | Which of your sites took the order. Up to 64 characters. |
customer (an object, all optional)
| Field | Type | Notes |
|---|---|---|
customer_id | text or whole number | Your own customer ID. Up to 255 characters, no spaces. |
first_order | true or false | Whether this is the customer's first order. |
email_sha256 | text | The SHA-256 of the normalised email address, 64 hexadecimal characters. |
phone_sha256 | text | The SHA-256 of the phone number in E.164 form, 64 hexadecimal characters. |
click (an object, all optional)
Send each click ID exactly as the ad platform set it, up to 1,024 characters with no spaces.
| Field | What it is |
|---|---|
gclid | Google's click ID. |
gbraid | Google's click ID for clicks on iOS devices that lead to app conversions. |
wbraid | Google's click ID for clicks on iOS devices that lead to web conversions. |
fbclid | Meta's click ID. |
ttclid | TikTok's click ID. |
visitor_id | Tapper's visitor ID, a UUID, if your site can read it. |
lines (a list, optional, up to 200 product lines)
| Field | Type | Required | Notes |
|---|---|---|---|
product_id | text or whole number | Yes | Your product ID. Up to 255 characters, no spaces. |
category | text | No | Up to 255 characters. |
quantity | whole number | Yes | 1 or more. |
unit_price | number | Yes | The price of one unit, 0 or more. |
A refund record (every field required)
| Field | Type | Notes |
|---|---|---|
event | refund | |
order_id | text or whole number | The order the refund belongs to. |
refund_id | text or whole number | Your refund ID. Sending the same refund ID again updates that refund. |
refunded_at | date and time | ISO 8601 with Z or an offset. |
amount | number | The amount refunded, 0 or more. |
currency | text | Must be the order's currency. |
A cancellation record (every field required)
| Field | Type | Notes |
|---|---|---|
event | cancellation | |
order_id | text or whole number | The order that was cancelled. |
cancelled_at | date and time | ISO 8601 with Z or an offset. |
How updates work
- An optional field you leave out of an
updatedrecord keeps the value Tapper already holds. - When a record carries
lines, those are the order's lines, by position. A line the new list no longer has is marked removed. A record withoutlinesleaves the lines as they were. - A refund or a cancellation needs its order to be stored first, earlier in the same batch or in an earlier one. Send each order before its refunds.
- No time may be more than a day in the future.
Hash email and phone
Hash on your servers, before the request leaves them. Normalise first, then hash, the same way every time, so the same address always gives the same hash.
- Remove spaces at the start and end.
- Convert to lowercase.
- Hash the result with SHA-256 and send it as 64 hexadecimal characters.
Phone
- Write the number in E.164 form: a
+, the country code, then the number, digits only (for example+971501234567). - Hash the result with SHA-256 and send it as 64 hexadecimal characters.
For example, jane.doe@example.com hashes to 86e0b9e56c17cc4d12387e1949b85053fbe73bc3ce5a1188713a9d300cc6133d, and +971501234567 to 5cd602a57e6a0414e38023888d6275b31208be50ab5cf4c1e7f197138da7fed1.
Example request and response
This batch sends an order, a partial refund of it, and a second order that wrongly carries a raw email address.
curl -sS -X POST "https://api.tapper.ai/order-feed/<id>/orders" \
-H "Content-Type: application/json" \
-H "X-Tapper-Order-Feed-Secret: $TAPPER_ORDER_FEED_SECRET" \
-d @batch.jsonbatch.json:
{
"records": [
{
"event": "placed",
"order_id": "100234",
"placed_at": "2026-10-15T09:30:00Z",
"currency": "AED",
"value": 349.5,
"status": "paid",
"value_ex_tax_shipping": 315,
"discount": 20,
"coupon_codes": ["WELCOME10"],
"payment_method": "card",
"sales_channel": "web",
"country": "AE",
"customer": {
"customer_id": "C-88231",
"first_order": true,
"email_sha256": "86e0b9e56c17cc4d12387e1949b85053fbe73bc3ce5a1188713a9d300cc6133d"
},
"click": {
"gclid": "EAIaIQobChMIq5bW4o2ziQMVvJiDBx2X7AqPEAAYASAAEgKj3_D_BwE"
},
"lines": [
{ "product_id": "SKU-4417", "category": "Kitchen", "quantity": 2, "unit_price": 99.75 },
{ "product_id": "SKU-0981", "quantity": 1, "unit_price": 150 }
]
},
{
"event": "refund",
"order_id": "100234",
"refund_id": "R-5521",
"refunded_at": "2026-10-16T08:05:00+04:00",
"amount": 99.75,
"currency": "AED"
},
{
"event": "placed",
"order_id": "100235",
"placed_at": "2026-10-15T10:02:00Z",
"currency": "AED",
"value": 89,
"status": "paid",
"customer": { "email": "jane.doe@example.com" }
}
]
}The response, 200:
{
"data": {
"test": false,
"received": 3,
"accepted": [
{ "index": 0, "event": "placed", "order_id": "100234", "outcome": "inserted" },
{ "index": 1, "event": "refund", "order_id": "100234", "refund_id": "R-5521", "outcome": "inserted" }
],
"rejected": [
{
"index": 2,
"event": "placed",
"order_id": "100235",
"reasons": [
"customer.email is a raw email address, which Tapper does not take: send customer.email_sha256, the SHA-256 of the trimmed, lower-cased address, instead."
]
}
]
}
}indexis the record's position inrecords, counting from 0.outcomeisinsertedfor a record Tapper did not hold,updatedfor one it did (sending the same batch again answersupdated), andvalidatedin a test batch.- A rejected record shows its
eventandorder_idonly when they passed their own checks; otherwise they arenull.
Other answers
| Status | When | What to do |
|---|---|---|
400 | The body itself is wrong: not JSON, records missing, empty or longer than 500, or test not true or false. No record is checked or stored. | Fix the body. The errors list names the field. |
401 | The secret is missing or wrong, or the address does not exist. | Check the address and the header. |
403 | The secret is right and the order feed is switched off. Nothing is stored. | Ask your Tapper contact to switch it on. |
413 | The body is larger than 8 MiB. | Send fewer records in each batch. |
429 | More than 300 batches in a minute from one IP address. | Wait a minute, then carry on. |
503 | Every record that passed its checks failed to store. | Send the whole batch again. Your order and refund IDs make that safe. |
If only some records fail to store, those come back in rejected with the reason Tapper could not store this record just now. Send it again.
Test without storing anything
Add "test": true to the body to check a batch against production without storing anything:
{
"test": true,
"records": [
{
"event": "placed",
"order_id": "100234",
"placed_at": "2026-10-15T09:30:00Z",
"currency": "aed",
"value": 349.5,
"status": "paid"
}
]
}Every record is checked exactly as in a real batch. Each accepted record comes back with outcome validated and a record field holding the record as Tapper would store it, for example with the currency in capitals:
{
"data": {
"test": true,
"received": 1,
"accepted": [
{
"index": 0,
"event": "placed",
"order_id": "100234",
"outcome": "validated",
"record": {
"event": "placed",
"order_id": "100234",
"placed_at": "2026-10-15T09:30:00Z",
"currency": "AED",
"value": 349.5,
"status": "paid"
}
}
],
"rejected": []
}
}A test batch checks each record on its own. It does not check that a refund's or a cancellation's order is already stored, because a test batch stores no order for it to find.
Send your order history
Send past orders through the same address, in batches of up to 500, as placed records, with their refunds and cancellations after them. Tapper takes orders placed up to 24 months ago; an older one is refused with the earliest date it takes, for example:
placed_at is more than 24 months ago. The order feed takes history back to 2024-10-16.Send up to 300 batches a minute from one IP address. Sending a batch again is safe: an order already stored is updated, not added twice.