cli.gs
Sign in

Postbacks to your shop

Receive status changes, verify the signature, handle retries.

cli.gs can tell your shop whenever a conversion changes status: recorded, approved, rejected or reversed. On this page you'll learn how to set up that status callback, how to verify its signature, and how retries work. Your shop's records then follow ours without polling, for example to mark an order as "commission approved" in your back office. This is optional; the integration works without it.

In short:

  • Enter an HTTPS URL as Postback URL under Status callback (optional) and click Save.
  • We send one signed POST per status change, with the same three X-Cligs-* headers you use yourself.
  • Verify the signature over the raw body, then answer 2xx within 5 seconds.
  • On 5xx, 408, 429 or a timeout we retry up to seven times. Any other 4xx stops delivery at once.

Set it up

  1. Open the integration page and scroll to Status callback (optional).
  2. Enter your endpoint in the Postback URL field.
  3. Click Save.

Requirements for the URL:

  • It must use HTTPS.
  • It must point to a public address. Addresses in private or internal networks are refused, both when you save and at every delivery.
  • Redirects aren't followed. Enter the final URL.
  • Deliveries are signed with your programme's signing secret, so you need one. It comes with your first API key: see Signed requests. Without a secret, nothing is sent.
  • Only status changes after you save the URL are sent. Earlier ones aren't delivered retroactively.

Events

EventSent when
conversion.recordedA conversion was recorded, including the ones your own postback reported and test conversions
conversion.approvedA conversion was approved: automatically after the approval window, or by you under Conversions
conversion.rejectedYou (or our review team) rejected a conversion
conversion.reversedYour shop reported a reversal

Each event is sent once per conversion. When a server postback confirms a parked pixel conversion, no new conversion.recorded is sent. The answer to your postback already tells you the result.

The request we send

text
POST https://shop.example.com/cligs/postback
Content-Type: application/json
User-Agent: cligs-postback/1
X-Cligs-Event: conversion.approved
X-Cligs-Timestamp: 1790000000
X-Cligs-Nonce: 9b1f0c6e2a4d4f8e8c3a7b5d1e2f3a4b
X-Cligs-Signature: 5c0d…e19a
json
{
  "event": "conversion.approved",
  "conversionId": "cmg3x1q2w0001abcd9876efgh",
  "orderId": "100234",
  "programId": "cmf8a7b6c0002wxyz1234lmno",
  "status": "APPROVED",
  "eventType": "SALE",
  "basketValue": "149.99",
  "commission": "12",
  "networkFee": "1.8",
  "currency": "EUR",
  "reason": null,
  "test": false,
  "occurredAt": "2026-09-28T14:05:00.000Z",
  "sentAt": "2026-10-12T03:00:41.512Z"
}
  • status is the conversion's status when the event happened. reason is set for rejections and reversals (e.g. RETURN).
  • Amounts are strings, to avoid rounding errors.
  • test: true marks events for test conversions. Ignore them in production, or use them to test this endpoint.
  • occurredAt is the time of the sale. sentAt is when this delivery attempt was made.

Verify the signature

The signature is made exactly like the one on your own requests, with the same headers and the same signing secret. The signed path is the path and query string of the URL you entered. For example /cligs/postback, or /cligs/postback?shop=1 if your URL has a query.

  1. Reject the request if X-Cligs-Timestamp is more than five minutes away from your clock.
  2. Build POST\n<path>\n<timestamp>\n<nonce>\n<hex SHA-256 of the raw body>.
  3. Calculate HMAC-SHA256 with your signing secret. Compare it with X-Cligs-Signature using a timing-safe comparison.
  4. Optionally remember nonces for five minutes and reject repeats.

Always hash the raw body as received. Parsing the JSON and serialising it again changes the bytes and breaks the signature.

Node.js (Express)

js
import express from "express";
import crypto from "node:crypto";

const SECRET = process.env.CLIGS_SIGNING_SECRET;
// The exact URL you entered as Postback URL. Used for the signed path, so a proxy
// that rewrites paths cannot break verification.
const POSTBACK_URL = new URL("https://shop.example.com/cligs/postback");
const SIGNED_PATH = POSTBACK_URL.pathname + POSTBACK_URL.search;

const app = express();

// express.raw keeps the body as bytes: we need them unchanged for the hash.
app.post(POSTBACK_URL.pathname, express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body.toString("utf8");
  const timestamp = req.get("X-Cligs-Timestamp") ?? "";
  const nonce = req.get("X-Cligs-Nonce") ?? "";
  const signature = req.get("X-Cligs-Signature") ?? "";

  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(401).send("stale timestamp");
  }

  const bodyHash = crypto.createHash("sha256").update(body, "utf8").digest("hex");
  const canonical = ["POST", SIGNED_PATH, timestamp, nonce, bodyHash].join("\n");
  const expected = Buffer.from(crypto.createHmac("sha256", SECRET).update(canonical).digest("hex"), "hex");
  const given = Buffer.from(signature, "hex");
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
    return res.status(401).send("bad signature");
  }

  const event = JSON.parse(body);
  res.status(204).end(); // answer first ...
  setImmediate(() => handleCligsEvent(event).catch(console.error)); // ... then do the work
});

async function handleCligsEvent(event) {
  // Deduplicate on conversionId + event: a delivery can arrive twice.
  // e.g. await db.orders.update({ id: event.orderId }, { affiliateStatus: event.status });
}

app.listen(3000);

PHP

php
<?php
// cligs-postback.php — the endpoint behind your Postback URL.
$secret     = getenv('CLIGS_SIGNING_SECRET');
$signedPath = '/cligs/postback'; // path (and query string, if any) of the URL you entered

$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_CLIGS_TIMESTAMP'] ?? '';
$nonce     = $_SERVER['HTTP_X_CLIGS_NONCE'] ?? '';
$signature = strtolower($_SERVER['HTTP_X_CLIGS_SIGNATURE'] ?? '');

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit('stale timestamp');
}

$canonical = implode("\n", ['POST', $signedPath, $timestamp, $nonce, hash('sha256', $body)]);
$expected  = hash_hmac('sha256', $canonical, $secret);
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('bad signature');
}

$event = json_decode($body, true);

// Store the event quickly and process it later (queue, cron job).
// Deduplicate on conversionId + event: a delivery can arrive twice.
store_cligs_event($event['conversionId'], $event['event'], $body);

http_response_code(204);

Answer quickly, and retries

What we expect from your endpoint

  • Answer with any 2xx status within 5 seconds. Store the event and process it afterwards. Slow work in the request risks a timeout, which counts as a failure.
  • Answer 5xx, not 4xx, for temporary problems on your side.

When we retry

  • On a timeout, a network error, HTTP 5xx, 408 or 429, we try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. That's seven attempts over roughly a day and a half. Every attempt is signed afresh with a new timestamp and nonce.
  • Any other 4xx (for example 400, 401, 404) is taken as a deliberate "no". We stop at once.
  • When a delivery finally fails, we notify you. You can see every delivery, its attempts and the last HTTP status under Postbacks sent to your shop on the integration page.

Next steps

Send a test conversion to check your endpoint end to end: see Testing and diagnostics.