> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thrad.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Server-to-server Conversions API

> Send conversions from your backend to Thrad for attribution and OpenAI measurement.

Send conversions from your backend to Thrad for higher-fidelity attribution. The Server-to-server Conversions API is the one advertiser integration for Thrad attribution and, when enabled for the tag, OpenAI Conversions API delivery.

> [!NOTE]
>
> This endpoint is enabled per tag. Ask your Thrad contact to issue an `events:write` key for the tag IDs your service may send to. Keep the key on your server; never expose it in browser or mobile code.

## Quickstart

The Thrad Tag captures browser activity and attribution context. For confirmed purchases, signups, subscriptions, and CRM events, send the conversion from your backend as well:

* More reliable: server requests are not affected by ad blockers or browser lifecycle failures.
* Richer: attach the order, line items, email, phone, or customer ID your frontend does not have.
* Deduplicated: reuse `event_id` across browser and server copies of the same conversion.
* Flexible: send offline, phone, app, and delayed lifecycle events.

## How it works

1. Install the [Thrad Tag](https://docs.thrad.ai/advertisers/attribution/tag/install.md) on your site. It captures consent-permitted Thrad and OpenAI attribution context.
2. At conversion, pass `window.thradTag.getServerContext()` from the browser to your backend with the order or lead details.
3. Your backend sends one event to Thrad. We record it once and route it to Thrad and/or enabled OpenAI measurement without the advertiser choosing a provider endpoint.

Provider delivery is controlled by Thrad. An accepted event and an `events:write` key do not by themselves turn on OpenAI forwarding; Thrad enables it only for an approved tag after the integration is ready. Your request format stays the same whether the event is used for Thrad attribution, OpenAI measurement, or both.

## Reference guides

* [Authentication](https://docs.thrad.ai/advertisers/attribution/server-events/authentication.md) explains how to obtain and use an `events:write` key.
* [Identity and consent](https://docs.thrad.ai/advertisers/attribution/server-events/identity-and-consent.md) covers hashed and plaintext matching data, privacy, and routing.
* [Testing](https://docs.thrad.ai/advertisers/attribution/server-events/testing.md) covers `test_mode` and common integration errors.
* [Event reference](https://docs.thrad.ai/advertisers/attribution/server-events/event-reference.md) lists event names, data types, and response outcomes.

## Step 1 — Install the Tag

Install the [Thrad Tag](https://docs.thrad.ai/advertisers/attribution/tag/install.md) on every page. The server integration also works for offline events without a browser context, but using the Tag improves attribution for web conversions.

## Step 2 — Read context on conversion

On a confirmation page or in your checkout client, read the context and send it to your own backend unchanged. It is safe to call after the Tag loads. It contains no API key or raw matching identity.

```js theme={null}
const context = window.thradTag.getServerContext();
await fetch("/api/complete-order", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ orderId, context }),
});
```

If the visitor did not arrive from a Thrad or OpenAI ad, the context may have no attribution reference. The conversion can still be recorded; include your customer identity when consent permits it to improve matching.

## Step 3 — Send the event from your backend

### Endpoint and authentication

```http theme={null}
POST https://events.thrad.ai/v1/api/events
Authorization: Bearer <THRAD_EVENTS_API_KEY>
Content-Type: application/json
```

Each key has both the `events:write` capability and an allowlist of tag IDs. A key cannot submit an event for another advertiser's tag.

| Status | Meaning |
| - | - |
| `200` | The envelope was processed. Inspect every result; valid siblings are not blocked by an invalid event. |
| `401` | The bearer token is missing, expired, revoked, or invalid. |
| `403` | The key does not have `events:write` or is not authorized for `tag_id`. |
| `422` | The envelope itself is malformed (for example, not JSON or more than 1,000 events). |
| `503` | Thrad could not determine a durable outcome. Retry the complete request with the same event IDs. |

Use an `events:write` key issued for the tag. Keep it server-side only. Send a stable event ID on every event and reuse it for retries and any matching browser event.

```bash theme={null}
curl -X POST https://events.thrad.ai/v1/api/events \
  -H "Authorization: Bearer $THRAD_EVENTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "version": 1,
    "tag_id": "adv_example_123",
    "events": [{
      "event_id": "order_12345",
      "event_name": "order_created",
      "timestamp_ms": 1773892800000,
      "action_source": "web",
      "source_url": "https://shop.example.com/checkout/confirmation",
      "context": { "version": 1, "oppref": "oppref_abc" },
      "user": {
        "emails_sha256": ["b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514"],
        "countries": ["US"]
      },
      "data": {
        "type": "contents",
        "amount": 2599,
        "currency": "USD",
        "contents": [{ "id": "sku_123", "quantity": 1 }]
      }
    }]
  }'
```

The timestamp is Unix milliseconds. It must be no more than seven days old or ten minutes in the future. `web` events require `source_url`.

## Envelope and results

An envelope has `version: 1`, one required `tag_id`, optional `test_mode`, and between one and 1,000 `events`. Unknown fields are rejected.

```json theme={null}
{
  "version": 1,
  "request_id": "req_...",
  "test_mode": false,
  "results": [
    {
      "index": 0,
      "event_id": "order_12345",
      "status": "accepted",
      "errors": [],
      "warnings": []
    }
  ]
}
```

Possible event statuses are `accepted`, `duplicate`, `suppressed`, `invalid`, and, in test mode, `validated`. An `invalid` event has one or more machine-readable `errors`; `duplicate` and `suppressed` events can include warnings. Retry only events whose outcome is not known (`503`), using their original `event_id`.

Set `test_mode: true` to validate the envelope and every event without creating a canonical record or forwarding to OpenAI. This is the safe integration check before sending live traffic.

## Events and data

Use these canonical event names: `page_viewed`, `contents_viewed`, `items_added`, `checkout_started`, `order_created`, `lead_created`, `registration_completed`, `appointment_scheduled`, `subscription_created`, `trial_started`, `app_installed`, `app_opened`, and `custom`.

Common aliases such as `purchase`, `add_to_cart`, `page_view`, and `signup` are normalized to their canonical names. `custom` requires `custom_event_name`. `app_installed` and `app_opened` require `action_source: "mobile_app"` and `data.type: "customer_action"`.

`data.type` must match the event:

| Data type | Use for |
| - | - |
| `contents` | Page, product, cart, checkout, and order events. Supports integer minor-unit `amount`, ISO currency, and `contents`. |
| `customer_action` | Leads, registrations, appointments, and app lifecycle events. |
| `plan_enrollment` | Trial and subscription events. Supports `plan_id`, amount, currency, and contents. |
| `custom` | Custom events. Supports the same commerce fields plus arbitrary JSON properties. |

## Identity and OpenAI advanced matching

Pre-hashed email and phone are the recommended default. Send lowercase 64-character SHA-256 values in `emails_sha256` and `phone_numbers_sha256`; include both whenever you collected them and consent permits it. Thrad also accepts `external_ids_sha256`, `first_names_sha256`, and `last_names_sha256`. Each field accepts up to three unique values; geographic fields (`countries`, `cities`, `regions`, `postal_codes`) remain raw.

For a simpler first integration, Thrad can hash plaintext `emails`, `phone_numbers`, `external_ids`, `first_names`, and `last_names` at ingress. Plaintext is normalized and discarded before logs, errors, canonical records, or queues are created. Prefer pre-hashing when it is easy to do in your service.

For example, this is the convenience form of the hashed identity in the request above:

```json theme={null}
{
  "user": {
    "emails": ["customer@example.com"],
    "phone_numbers": ["+1 (415) 555-0123"],
    "external_ids": ["customer-123"]
  }
}
```

Do not send plaintext fields in `data.custom_fields` or any other field. Use either form only when measurement consent is present. Thrad merges supplied hashes and locally generated hashes, deduplicates them, and retains at most three values per identity field.

Normalize before hashing exactly as follows:

* email: trim and lowercase;
* phone: convert to international digits-only form, remove `+` or `00` and punctuation, then keep 8–15 digits;
* external ID: trim only; preserve case;
* first and last name: lowercase and remove whitespace and ASCII punctuation; preserve non-ASCII characters.

The following shared vectors let you verify that your implementation agrees with Thrad. They use example values only.

| Field | Input | Normalized value | SHA-256 |
| - | - | - | - |
| email | `Test@Example.COM` | `test@example.com` | `973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b` |
| phone | `+1 (415) 555-0123` | `14155550123` | `413ba75461ab5f99d36820e561ea97e2bd80f9cb586f7ecea6cf4c496518950a` |
| external ID | `customer-123` | `customer-123` | `a28b5da378b043b0e62b924e1b055bb96ada180943e81cfc9838cf9933a91f03` |
| first name | `Jane` | `jane` | `81f8f6dde88365f3928796ec7aa53f72820b06db8664f5fe76a7eb13e24546a2` |
| last name | `Doe` | `doe` | `799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f` |

### Dependency-free hashing examples

Node.js:

```js theme={null}
import { createHash } from "node:crypto";

const sha256 = (value) => createHash("sha256").update(value, "utf8").digest("hex");
const normalizeEmail = (value) => value.trim().toLowerCase();
const normalizePhone = (value) => {
  const normalized = value.trim().replace(/[\s().-]/g, "").replace(/^(?:\+|00)/, "");
  if (!/^[1-9]\d{7,14}$/.test(normalized)) throw new Error("Use an international phone number");
  return normalized;
};
const normalizeExternalId = (value) => value.trim();
const asciiPunctuation = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~');
const normalizeName = (value) => [...value.trim().toLowerCase()]
  .filter((character) => !/\s/u.test(character) && !asciiPunctuation.has(character))
  .join("");

const emailHash = sha256(normalizeEmail(email));
const phoneHash = sha256(normalizePhone(phone));
const externalIdHash = sha256(normalizeExternalId(externalId));
const firstNameHash = sha256(normalizeName(firstName));
```

Python:

```python theme={null}
import hashlib
import re
import string

sha256 = lambda value: hashlib.sha256(value.encode("utf-8")).hexdigest()
normalize_email = lambda value: value.strip().lower()
def normalize_phone(value):
    normalized = re.sub(r"^(?:\+|00)|[\s().-]", "", value.strip())
    if re.fullmatch(r"[1-9][0-9]{7,14}", normalized) is None:
        raise ValueError("Use an international phone number")
    return normalized

normalize_external_id = lambda value: value.strip()
normalize_name = lambda value: "".join(
    char for char in value.strip().lower()
    if not char.isspace() and char not in string.punctuation
)

email_hash = sha256(normalize_email(email))
phone_hash = sha256(normalize_phone(phone))
external_id_hash = sha256(normalize_external_id(external_id))
first_name_hash = sha256(normalize_name(first_name))
```

PHP:

```php theme={null}
$sha256 = fn(string $value): string => hash('sha256', $value);
$normalizeEmail = fn(string $value): string => strtolower(trim($value));
$normalizePhone = function (string $value): string {
  $normalized = preg_replace('/^\\+|^00|[\\s().-]/', '', trim($value));
  if (!preg_match('/^[1-9][0-9]{7,14}$/', $normalized)) {
    throw new InvalidArgumentException('Use an international phone number');
  }
  return $normalized;
};
$normalizeExternalId = fn(string $value): string => trim($value);
$normalizeName = fn(string $value): string => str_replace(
  str_split("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"),
  '',
  preg_replace('/\\s+/', '', strtolower(trim($value)))
);

$emailHash = $sha256($normalizeEmail($email));
$phoneHash = $sha256($normalizePhone($phone));
$externalIdHash = $sha256($normalizeExternalId($externalId));
$firstNameHash = $sha256($normalizeName($firstName));
```

Reject a phone number unless the normalized value is 8–15 digits and does not begin with `0`. If your runtime cannot lowercase non-ASCII names consistently, use pre-hashed name values instead of relying on the plaintext convenience path.

Use `context.oppref` unchanged when your server captured it from the Thrad/OpenAI browser flow. `context.oppcref` is an unchanged lowercase UUIDv4 and is valid only for page-view, item-added, checkout-started, and order-created events. Send `user.obref` unchanged when available and permitted by measurement consent.

## Consent, routing, and deduplication

`consent.measurement` and `consent.personalization` default to `true`. Set `measurement: false` when the user has not consented to measurement. The event is returned as `suppressed` and is neither recorded nor forwarded.

The same canonical event can route to Thrad, OpenAI, or both. A signed Thrad click context (`click_id`, `click_exp`, and `click_sig`) enables Thrad attribution. An OpenAI reference (`oppref`, `oppcref`, or `obref`) or a supported advanced-matching identifier enables OpenAI eligibility. Your advertiser-visible API response does not expose provider credentials or routing internals.

### Using the Browser Tag and Server-to-server CAPI together

When the same purchase is reported by the updated [Browser Tag](https://docs.thrad.ai/advertisers/attribution/tag/conversions.md)
or [Shopify Custom Pixel](https://docs.thrad.ai/advertisers/attribution/tag/guides/shopify.md) and this API,
the server must use the browser's canonical purchase identity:

```text theme={null}
purchase:v1: + lowercase SHA256(UTF8(compact JSON [trim(tag_id), normalized_order_id]))
```

Trim the order ID. Normalize only an exact `gid://shopify/Order/<positive numeric ID>`
to its numeric suffix; the equivalent numeric string uses that same suffix.
Preserve other order IDs unchanged. JSON contains two strings, no spaces and
literal Unicode. Do not use a cart/checkout token, callback ID, amount or timestamp
as order identity. Different tag IDs produce different purchase identities.

Node.js example for your server's finalized order:

```js theme={null}
import { createHash } from "node:crypto";

function purchaseEventId(tagId, orderId) {
  if (typeof tagId !== "string" || typeof orderId !== "string") {
    throw new Error("Tag ID and final order ID must be strings");
  }
  const tag = tagId.trim();
  const order = orderId.trim().replace(/^gid:\/\/shopify\/Order\/([1-9][0-9]*)$/, "$1");
  if (!tag || !order) throw new Error("Tag ID and final order ID are required");
  return "purchase:v1:" + createHash("sha256")
    .update(JSON.stringify([tag, order]), "utf8").digest("hex");
}

const eventId = purchaseEventId(tagId, finalShopifyOrderId);
```

Send this ID as the purchase event's `event_id` in the existing `/v1/api/events`
envelope, and reuse it for retries. The updated browser can omit `event_id` and
derive the same value; an explicitly supplied browser ID must match. This S2S
schema does not accept an added `order_id` field. Thrad's internal purchase-identity
reservation endpoint is service-only and is not an advertiser API.

Thrad must confirm release availability and reconcile existing purchase producers
before cutover. A hosted pixel update cannot change your server's existing random
ID generator or recover historical order mappings. Preserve a working server
integration while those identities are checked.

Independent S2S events and conversions outside the updated browser purchase path
can retain their valid stable IDs. Do not change unrelated IDs or generate a new
ID for a retry. Thrad deduplicates per `tag_id` and `event_id`; matching provider
delivery is checked separately from a canonical receipt.

## Troubleshooting

* `invalid_timestamp`: correct the system clock or send the event within the supported time window.
* `invalid_identity`: send valid lowercase SHA-256 hex values, or send supported plaintext arrays for local hashing.
* `invalid_context`: do not alter click signatures, `oppref`, `oppcref`, or `obref`.
* `no_attribution_signal`: include a signed Thrad context, an OpenAI reference, or a supported matching identifier.
* `measurement_consent_denied`: do not retry until measurement consent is granted for a new event.
* `duplicate_event`: the original request was already accepted; do not create a new ID for a retry.

This is the only server-to-server events API for new integrations.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.