ByteVerseDevelopers
Get API keys

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.

  1. Create an endpointRegister a public https URL. Save the secret from 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);
  1. Verify the signatureReject anything that does not verify (code below).
  2. Respond with 2xx quicklyDo heavy work after you answer. ByteVerse waits at most 10 seconds.
  3. Send a test eventCall POST /v1/webhook_endpoints/{id}/test to confirm everything is wired up.

The event

Example: campaign.progress
{
  "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

TypeSent when
campaign.createdA campaign was created through the API.
campaign.activatedByteVerse approved the campaign and it is now live.
campaign.pausedYou paused the campaign.
campaign.resumedYou resumed the campaign.
campaign.progressVerified actions crossed 25%, 50%, 75% or 100% of the target. The campaign object includes a milestone field.
campaign.completedAll target actions have been verified.
campaign.cancelledThe campaign was cancelled. Includes refund_amount.
submission.approvedByteVerse approved a submission on one of your live campaigns. The data is the anonymous submission object (see List submissions).
submission.rejectedA submission on one of your live campaigns failed verification. Same shape; not billed.
wallet.creditedByteVerse credited your live wallet (for example after a bank transfer).
webhook.testSent when you call the test endpoint.

Request headers

HeaderValue
ByteVerse-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
ByteVerse-Event-IdEvent id. Stable across retries: use it to deduplicate.
ByteVerse-Event-Typee.g. campaign.completed
ByteVerse-Delivery-IdId of this delivery
ByteVerse-Delivery-Attempt1 for the first try, then 2, 3 ...
User-AgentByteVerse-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.id and ignore repeats.
  • Order is not guaranteed. Use created or fetch the campaign if you need the latest state.
  • Success means any 2xx response within 10 seconds. Redirects are not followed.

Retries

AttemptRetried after
11 minute
25 minutes
330 minutes
42 hours
56 hours
612 hours
724 hours
8Marked 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 https on 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.