Guides
Webhooks
Webhooks tell your server when something happens to a campaign, so you never have to poll. ByteVerse sends an HTTPS POST with a signed JSON body.
- Create an endpointRegister a public https URL. Save the
secretfrom the response, because it is shown only once.
curl -X POST "https://api.byteverseafrica.com/v1/webhook_endpoints" \
-H "Authorization: Bearer $BYTEVERSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.arewathreads.ng/webhooks/byteverse",
"events": [
"campaign.activated",
"campaign.progress",
"campaign.completed"
]
}'const res = await fetch("https://api.byteverseafrica.com/v1/webhook_endpoints", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BYTEVERSE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://api.arewathreads.ng/webhooks/byteverse",
"events": [
"campaign.activated",
"campaign.progress",
"campaign.completed"
]
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://api.byteverseafrica.com/v1/webhook_endpoints",
headers={"Authorization": f"Bearer {os.environ['BYTEVERSE_API_KEY']}"},
json={
"url": "https://api.arewathreads.ng/webhooks/byteverse",
"events": ["campaign.activated", "campaign.progress", "campaign.completed"],
},
timeout=30,
)
data = res.json()<?php
$ch = curl_init("https://api.byteverseafrica.com/v1/webhook_endpoints");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BYTEVERSE_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode([
"url" => "https://api.arewathreads.ng/webhooks/byteverse",
"events" => ["campaign.activated", "campaign.progress", "campaign.completed"],
]),
]);
$data = json_decode(curl_exec($ch), true);- Verify the signatureReject anything that does not verify (code below).
- Respond with 2xx quicklyDo heavy work after you answer. ByteVerse waits at most 10 seconds.
- Send a test eventCall
POST /v1/webhook_endpoints/{id}/testto confirm everything is wired up.
The event
{
"id": "evt_5b2e9a41c7d84f03a6b1e2c9",
"object": "event",
"type": "campaign.progress",
"created": 1792660800,
"environment": "live",
"data": {
"object": {
"id": "cmp_8f3a1c9d2b7e4f60a5d1",
"object": "campaign",
"environment": "live",
"status": "active",
"name": "Sallah Weekend Ankara Sale",
"external_reference": "order_10482",
"metadata": {
"order_id": "10482"
},
"task_type": "instagram-follow",
"task_label": "Instagram Follow",
"destination_url": "https://instagram.com/arewathreads",
"caption": null,
"media_url": null,
"targeting": {
"locations": [
"Lagos",
"Kano"
],
"gender": "all"
},
"price_per_action": 70,
"budget": {
"currency": "NGN",
"amount": 5000,
"service_fee": 250,
"total_charged": 5250,
"spent": 2100,
"remaining": 2900
},
"progress": {
"target_actions": 71,
"verified_actions": 30,
"rejected_actions": 3,
"pending_actions": 4,
"completion_percent": 42.3
},
"tracking_url": "https://business.byteverseafrica.com/campaign-dashboard.html?campaign_id=cmp_8f3a1c9d2b7e4f60a5d1",
"created_at": "2026-10-22T09:14:03Z",
"completed_at": null,
"cancelled_at": null,
"refund_amount": null,
"milestone": 25
}
}
}data.object is the full campaign as it looked when the event happened (plus milestone for progress events), so you usually do not need a follow-up API call.
Event types
| Type | Sent when |
|---|---|
campaign.created | A campaign was created through the API. |
campaign.activated | ByteVerse approved the campaign and it is now live. |
campaign.paused | You paused the campaign. |
campaign.resumed | You resumed the campaign. |
campaign.progress | Verified actions crossed 25%, 50%, 75% or 100% of the target. The campaign object includes a milestone field. |
campaign.completed | All target actions have been verified. |
campaign.cancelled | The campaign was cancelled. Includes refund_amount. |
submission.approved | ByteVerse approved a submission on one of your live campaigns. The data is the anonymous submission object (see List submissions). |
submission.rejected | A submission on one of your live campaigns failed verification. Same shape; not billed. |
wallet.credited | ByteVerse credited your live wallet (for example after a bank transfer). |
webhook.test | Sent when you call the test endpoint. |
Request headers
| Header | Value |
|---|---|
ByteVerse-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
ByteVerse-Event-Id | Event id. Stable across retries: use it to deduplicate. |
ByteVerse-Event-Type | e.g. campaign.completed |
ByteVerse-Delivery-Id | Id of this delivery |
ByteVerse-Delivery-Attempt | 1 for the first try, then 2, 3 ... |
User-Agent | ByteVerse-Webhooks/1.0 |
Verifying signatures
The signature is an HMAC-SHA256, keyed with your endpoint secret, over the string {timestamp}.{raw request body}. Use the raw body bytes. Re-serializing parsed JSON changes the bytes and breaks the check. Compare in constant time and reject timestamps older than five minutes to stop replays.
import crypto from "node:crypto";
// rawBody must be the exact bytes received (do NOT re-serialize parsed JSON).
export function verifyByteVerse(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // too old: possible replay
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example
app.post("/webhooks/byteverse", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyByteVerse(req.body.toString("utf8"), req.header("ByteVerse-Signature") ?? "", process.env.BYTEVERSE_WHSEC);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// 1) store event.id and skip if seen 2) enqueue work 3) answer fast
res.sendStatus(200);
});import hmac, hashlib, time
def verify_byteverse(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = int(parts.get("t", "0"))
if not t or abs(time.time() - t) > tolerance:
return False # too old: possible replay
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask example
@app.post("/webhooks/byteverse")
def byteverse_webhook():
if not verify_byteverse(request.get_data(), request.headers.get("ByteVerse-Signature", ""), os.environ["BYTEVERSE_WHSEC"]):
return "bad signature", 400
event = request.get_json()
# 1) store event["id"] and skip if seen 2) enqueue work 3) answer fast
return "", 200<?php
function verify_byteverse(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(",", $header) as $p) { [$k, $v] = array_pad(explode("=", $p, 2), 2, ""); $parts[$k] = $v; }
$t = (int)($parts["t"] ?? 0);
if (!$t || abs(time() - $t) > $tolerance) return false; // too old: possible replay
$expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
return hash_equals($expected, $parts["v1"] ?? "");
}
$raw = file_get_contents("php://input");
if (!verify_byteverse($raw, $_SERVER["HTTP_BYTEVERSE_SIGNATURE"] ?? "", getenv("BYTEVERSE_WHSEC"))) { http_response_code(400); exit("bad signature"); }
$event = json_decode($raw, true);
// 1) store $event["id"] and skip if seen 2) enqueue work 3) answer fast
http_response_code(200);Delivery guarantees
- At least once. You may receive the same event more than once. Store
event.idand ignore repeats. - Order is not guaranteed. Use
createdor fetch the campaign if you need the latest state. - Success means any
2xxresponse within 10 seconds. Redirects are not followed.
Retries
| Attempt | Retried after |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| 8 | Marked dead (no more retries) |
After 50 consecutive failures an endpoint is automatically disabled. Fix your server, then re-enable it with POST /v1/webhook_endpoints/{id} and {"status":"active"}.
Catching up
Events are kept for 30 days. If your server was down, list them with GET /v1/events (filter by type) and process anything you missed. Use GET /v1/webhook_endpoints/{id}/deliveries to see exactly what happened to each attempt, including the HTTP status and error.
Security
- Endpoints must be
httpson port 443 or 8443 and resolve to a public address. Local, private and internal hosts are rejected when you register and again at delivery time. - Rotate a secret with
POST /v1/webhook_endpoints/{id}/rotate_secret. The old secret stops working immediately. - Test and live webhooks are separate: register an endpoint with each kind of key.
