Adsly.pro
Documentation pages All documentation

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.

View as Markdown

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

EventWhen
status_changeA 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_digestAt 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.
pingWhen you press Test on the key.

status_change

{
  "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

{
  "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)

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)

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). 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.

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

Updated 2026-10-08

Discuss your project