# Any platform

> One signed webhook away.

## How it works

On a conversion your server fires one HMAC-SHA256-signed POST to our postback endpoint. We verify the signature, attribute the conversion to the affiliate whose link drove it, and record the commission.

We deduplicate on `externalId`, so retries and double-fires are safe. If a sale is later refunded we claw the commission back automatically, within your configured refund window.

This works for web checkout, games, SaaS, no-code site builders, or any custom platform where your server can make an outbound HTTPS request at the moment of conversion.

## Setup

1. **Grab your signing secret.** Log in, go to Integrations, and copy the postback secret for your surface. Keep it server-side only.
2. **Sign the request.** HMAC-SHA256 sign `"${timestamp}." + rawBody` with the secret. Timestamp is unix **seconds** as a string. Prefix the hex digest with `sha256=`.
3. **POST to the conversions endpoint.** Send the JSON body with `x-ea-surface` (your surface id), `x-ea-timestamp` (unix seconds), and `x-ea-signature` (`sha256=` + hex). Include `clickId` from the tracking link when you have it.
4. **We attribute, dedupe, and clawback.** We respond `200` with attribution status. Retry on network errors: duplicate `externalId`s are safe.

## Sign and send

**Signed conversion postback**

Timestamp is unix **seconds** (not ms). Signature is `sha256=` + hex of HMAC-SHA256 over `"${timestamp}." + rawBody`. The skew window is 300 seconds.

```javascript
import crypto from "node:crypto";

const POSTBACK_URL = "https://api.attribloom.com/v1/conversions";
const SURFACE_ID   = "<your-surface-id>";
const SECRET       = process.env.ATTRIBLOOM_POSTBACK_SECRET; // keep server-side only

const ts   = Math.floor(Date.now() / 1000).toString();  // unix SECONDS (not ms)
const body = JSON.stringify({
  externalId: "order_12345",           // your stable id for this conversion (dedupe key)
  eventType:  "sale",                  // sale | subscribe | install | ...
  grossMinor: 4999,                    // 49.99 in minor units (integer)
  currency:   "USD",
  occurredAt: new Date().toISOString(),
  clickId:    "<click id from the tracking link>", // optional but improves attribution
});

// Sign "${timestamp}." + rawBody with the surface secret, prefixed with "sha256=".
const sig = "sha256=" + crypto
  .createHmac("sha256", SECRET)
  .update(`${ts}.`)
  .update(body)
  .digest("hex");

const res = await fetch(POSTBACK_URL, {
  method:  "POST",
  headers: {
    "content-type":   "application/json",
    "x-ea-surface":   SURFACE_ID,
    "x-ea-timestamp": ts,   // unix seconds string
    "x-ea-signature": sig,  // "sha256=" + hex
  },
  body,
});
// 200 = accepted (idempotent on externalId). 401 = bad signature/skew. 400 = bad body.
```

> **Seconds, not milliseconds:** The timestamp must be unix **seconds** and the signature must carry the `sha256=` prefix. A millisecond timestamp or a bare hex digest fails the 300s skew or prefix check and returns `401`.

## Report a refund

POST to `/v1/conversions/refund` with the same surface signing headers. `externalId` identifies the original conversion. `refundId` is the stable idempotency key for this refund event. `refundedMinor` is the integer amount refunded by this event, so a partial refund sends only the partial amount.

**Signed full or partial refund**

```javascript
const REFUND_URL = "https://api.attribloom.com/v1/conversions/refund";
const ts = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify({
  externalId: "order_12345",  // the original conversion externalId
  refundId: "refund_67890",   // stable id for this refund event
  refundedMinor: 2500,         // this partial refund: 25.00 USD
  currency: "USD",
  eventType: "refund",         // or refund_reversed
});
const sig = "sha256=" + crypto
  .createHmac("sha256", SECRET)
  .update(`${ts}.`)
  .update(body)
  .digest("hex");

const res = await fetch(REFUND_URL, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-ea-surface": SURFACE_ID,
    "x-ea-timestamp": ts,
    "x-ea-signature": sig,
  },
  body,
});
// Replaying the same refundId is safe. 404 means externalId was not found.
// 409 means the currency differs from the original conversion.
```

| HTTP | Meaning and action |
| --- | --- |
| `200` | Applied, or the same `refundId` was already applied. Do not retry. |
| `400` | Invalid body. Fix the request before retrying. |
| `401` | Missing headers, bad secret/signature, or timestamp skew over 300 seconds. |
| `404` | No conversion matches `externalId` on this surface. |
| `409` | Refund currency does not match the original conversion currency. |
| `5xx` | Transient failure. Retry with backoff, a fresh timestamp, and a fresh signature. |

> **Refund reversal:** If your platform reverses a previously reported refund, send the required `refundId` with `eventType: refund_reversed`. Replaying the reversal is safe because Attribloom only reverses a conversion still marked refunded. Do not use `refund_reversed` for a second refund.

The complete machine contract, including request and response schemas, is at [OpenAPI 3.1](https://api.attribloom.com/openapi.json).
