> Adsly docs index: https://adsly.pro/docs/llms.txt · every page in one file: https://adsly.pro/docs/llms-full.txt · OpenAPI: https://adsly.pro/docs/api/openapi.yaml

# Webhooks

Signed HTTPS callbacks from Adsly — status_change the moment a campaign changes status, hourly_digest with every hour's numbers — headers, payloads, signature verification in Node.js and Python, retries.

Instead of polling, let Adsly call you. Set a **Postback URL** on a key (panel → API & Webhooks → the key's card) and every event for the cabinets that key reaches is POSTed there, signed with the key's webhook secret.

## Events

| Event | When |
|---|---|
| `status_change` | A campaign's status changed (In Review → Active, Active → Stopped…). Sent after each sync of the cabinet, and immediately when campaigns are switched on or off in the panel or through the API. |
| `hourly_digest` | At 5 minutes past each hour, with the numbers of the hour that just closed — one POST per cabinet. Hours with no views, clicks or spend are skipped. |
| `ping` | When you press **Test** on the key. |

### status_change

```json
{
  "event": "status_change",
  "delivered_at": "2026-10-08T12:00:05.123Z",
  "account_id": 53,
  "changes": [
    { "ad_id": 90412, "tme_path": "yourchannel", "old_status": "In Review", "new_status": "Active" },
    { "ad_id": 90388, "tme_path": "yourbot?start=promo", "old_status": "Active", "new_status": "Stopped" }
  ]
}
```

### hourly_digest

```json
{
  "event": "hourly_digest",
  "delivered_at": "2026-10-08T14:05:00.000Z",
  "period": { "start": "2026-10-08T13:00:00.000Z", "end": "2026-10-08T14:00:00.000Z" },
  "account_id": 53,
  "totals": { "views": 18420, "clicks": 137, "spent": 12.456, "actions": 9 },
  "campaigns": [
    { "ad_id": 90412, "tme_path": "yourchannel", "title": "Crypto channels — notes", "status": "Active",
      "views": 9100, "clicks": 73, "spent": 6.221, "actions": 4, "cpm": "0.68", "budget": "100.00" }
  ]
}
```

`campaigns` holds at most the top 200 by spend; when more campaigns moved, a `truncated` block says how many were left out. `totals` always cover all of them. Money is in the cabinet's currency.

## Headers

```
Content-Type:      application/json
User-Agent:        Adsly-Webhook/1.0 (+https://adsly.pro)
X-Adsly-Event:     status_change | hourly_digest | ping
X-Adsly-Delivery:  <uuid — the same on a retry, dedupe on it>
X-Adsly-Timestamp: <unix seconds>
X-Adsly-Signature: sha256=<hex>
```

## Verify the signature

The signature is HMAC-SHA256 of `<X-Adsly-Timestamp>.<raw request body>` with the key's webhook secret (shown when the key is created or the secret rotated). Verify it over the **raw** body — parsing and re-serialising the JSON changes the bytes. Reject requests older than 5 minutes.

### Node.js (Express)

```js
const express = require('express');
const crypto = require('crypto');

const app = express();
const SECRET = process.env.ADSLY_WEBHOOK_SECRET;

app.post('/adsly-webhook', express.raw({ type: 'application/json', limit: '2mb' }), (req, res) => {
  const ts = req.get('X-Adsly-Timestamp');
  const sig = req.get('X-Adsly-Signature') || '';
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
    .update(`${ts}.${req.body.toString('utf8')}`)
    .digest('hex');
  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);               // answer fast, then do the work
  queue.push(event);                 // e.g. hand off to a job queue
});
```

### Python (Flask)

```python
import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["ADSLY_WEBHOOK_SECRET"].encode()

@app.post("/adsly-webhook")
def adsly_webhook():
    ts = request.headers.get("X-Adsly-Timestamp", "")
    sig = request.headers.get("X-Adsly-Signature", "")
    if not ts or abs(time.time() - int(ts)) > 300:
        abort(400)
    body = request.get_data()                       # raw bytes
    expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)
    event = request.get_json()
    # … queue the work …
    return "", 200
```

## Delivery

- Your endpoint must be public **HTTPS**. `http://`, localhost and private addresses are refused.
- Answer with any `2xx` within **10 seconds**. Do slow work after answering.
- On a network error or a `5xx` we retry **once**, 750 ms later, with the same `X-Adsly-Delivery`. A `4xx` is not retried.
- Redirects are not followed.
- The key's card in the panel shows the last delivery's time, status and error.

## Which cabinets

A key receives events for the cabinets it reaches ([Keys and access](https://adsly.pro/docs/api/authentication.md)). An agency team member's key receives them for the cabinets shared with them, and stops the moment that access ends.

## Testing

Press **Test** on the key's card: you receive a `ping` event signed like the real ones.

```json
{ "event": "ping", "delivered_at": "2026-10-08T12:00:00.000Z", "message": "This is a test ping from Adsly…", "key_id": 41 }
```

---
Page: https://adsly.pro/docs/api/webhooks/ · Updated: 2026-10-08
