Skip to content
DocsWebhooks

Webhooks

Order, refill, price and balance changes pushed to your server as they happen, signed so you can check they came from us. No polling.

Set up an endpoint

  1. Build a route on your server that accepts a JSON POST and answers 2xx quickly.
  2. Add it in the panel under API & webhooks, or with POST /v1/webhooks. Pick the events you want, or leave them out to get all of them.
  3. Store the signing secret (whsec_…) and verify every request with it.
  4. Send a test event and check that it arrives.

The URL must be public HTTPS on port 443 or 8443, without a user name or password in it. Addresses that resolve to private, loopback or reserved networks are refused, and the address is checked again on every delivery. You can have up to 10 endpoints per environment.

Endpoints belong to an environment. One added with a live key, or in the panel’s Live view, gets live events; one added with a test key gets sandbox events.

Events

EventWhendata
order.status_changedAny change of an order’s status.Order
order.startedDelivery started (in_progress).Order
order.completedDelivered in full.Order
order.partialClosed with part undelivered and refunded.Order
order.canceledClosed before delivery and refunded.Order
refill.completedA refill finished.Refill
refill.rejectedThe supplier refused a refill.Refill
service.changedA price change on any service was announced or applied, or a service was paused or resumed. Live endpoints only.Announcement
balance.lowYour live balance dropped below the low-balance alert in your panel settings ($10 unless you changed it). Sent once, then again only after the balance has been back above it. Live endpoints only.Balance

A status change sends order.status_changed and, for the four statuses above, the specific event as well, as two separate deliveries. Subscribe to one or the other, or you will handle the change twice. A testping reaches every endpoint whatever events it listens to.

Payload

Every delivery is a JSON object with the same envelope. data is the object that changed, in the same shape as the API returns it (orders without events and rerouted).

NameTypeDescription
idstringEvent ID, evt_…. The same on every retry of one delivery: use it to ignore duplicates.
typestringThe event, for example order.completed.
created_atstringWhen the event happened, ISO 8601 in UTC.
livemodebooleanfalse for sandbox events.
dataobjectSee the tabs below.
{
  "id": "evt_01926e2b-4c1f-7a3d-8e52-9b0f4d6c2a17",
  "type": "order.completed",
  "created_at": "2026-09-26T16:47:30Z",
  "livemode": true,
  "data": {
    "id": 58213,
    "object": "order",
    "service": {
      "id": 1001,
      "name": "Instagram Followers",
      "platform": "instagram"
    },
    "link": "https://www.instagram.com/yourbrand",
    "quantity": 1000,
    "status": "completed",
    "start_count": 18420,
    "delivered": 1000,
    "remains": 0,
    "rate": "2.4000",
    "charge": "2.4000",
    "refunded": "0.0000",
    "currency": "USD",
    "runs": null,
    "interval": null,
    "cancel_requested": false,
    "can_cancel": true,
    "refill": {
      "available": true,
      "until": "2026-11-25T16:47:30Z"
    },
    "livemode": true,
    "created_at": "2026-09-26T14:02:11Z",
    "started_at": "2026-09-26T14:09:40Z",
    "completed_at": "2026-09-26T16:47:30Z",
    "updated_at": "2026-09-26T16:47:30Z"
  }
}
{
  "id": "evt_01928a10-77d2-7b0e-a4c3-51e6f09d2b84",
  "type": "refill.completed",
  "created_at": "2026-10-08T15:40:27Z",
  "livemode": true,
  "data": {
    "id": 912,
    "object": "refill",
    "order": 58214,
    "status": "completed",
    "created_at": "2026-10-08T09:15:03Z",
    "completed_at": "2026-10-08T15:40:27Z"
  }
}
# type is price, paused or resumed; new_rate is the new list price per 1,000
# (before your discount), or null
{
  "id": "evt_01926f40-1b9a-7c55-9d21-7e3a8c0b5f63",
  "type": "service.changed",
  "created_at": "2026-09-26T14:00:00Z",
  "livemode": true,
  "data": {
    "service": {
      "id": 1001,
      "name": "Instagram Followers"
    },
    "type": "price",
    "message": "$2.4000 → $2.6400 per 1K from 28 Sep, 14:00 UTC",
    "new_rate": "2.6400",
    "effective_at": "2026-09-28T14:00:00Z"
  }
}
{
  "id": "evt_01926e31-9f07-7d4b-b6a8-2c5d7e1f0a39",
  "type": "balance.low",
  "created_at": "2026-09-26T14:02:11Z",
  "livemode": true,
  "data": {
    "balance": "8.1000",
    "threshold": "10.0000",
    "currency": "USD"
  }
}

Headers

Content-Typeapplication/json
User-AgentOrbismm-Webhooks/1.0
Orbismm-EventThe event type, for example order.completed.
Orbismm-DeliveryDelivery ID, the same on every retry. It matches id in the endpoint’s recent_deliveries.
Orbismm-Signaturet=<unix time>,v1=<signature>. See below.

Verify signatures

v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your endpoint’s whole secret, whsec_ included. t is when this attempt was sent, so it changes on retries.

  1. Split the header on commas and take t and v1.
  2. Reject the request if t is more than 5 minutes from your clock. That stops replays.
  3. Compute the HMAC over t, a dot and the body exactly as received. Don’t parse and re-encode the JSON first: any change to spacing or escaping breaks the signature.
  4. Compare with v1 in constant time.
import crypto from 'node:crypto'

// rawBody: the request body exactly as received (string or Buffer), before any JSON parsing
export function verifyOrbismm(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=', 2)))
  const t = Number(parts.t)
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  const given = Buffer.from(parts.v1 ?? '')
  return given.length === expected.length && crypto.timingSafeEqual(given, Buffer.from(expected))
}

// Express: keep the raw body for this route
app.post('/orbismm', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyOrbismm(req.body, req.get('Orbismm-Signature') ?? '', process.env.ORBISMM_WEBHOOK_SECRET)) {
    return res.sendStatus(400)
  }
  const event = JSON.parse(req.body)
  res.sendStatus(200) // answer first, then do the work
  handle(event)
})
<?php

function verify_orbismm(string $body, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $pair) {
        [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, '');
        $parts[$k] = $v;
    }
    $t = (int) ($parts['t'] ?? 0);
    if ($t === 0 || abs(time() - $t) > $tolerance) {
        return false;
    }

    return hash_equals(hash_hmac('sha256', "{$t}.{$body}", $secret), $parts['v1'] ?? '');
}

$body = file_get_contents('php://input');
if (! verify_orbismm($body, $_SERVER['HTTP_ORBISMM_SIGNATURE'] ?? '', getenv('ORBISMM_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}
$event = json_decode($body, true);
import hashlib
import hmac
import time


def verify_orbismm(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t = int(parts["t"]) if parts.get("t", "").isdigit() else 0
    if t == 0 or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))


# Flask
@app.post("/orbismm")
def orbismm():
    if not verify_orbismm(request.get_data(), request.headers.get("Orbismm-Signature", ""), os.environ["ORBISMM_WEBHOOK_SECRET"]):
        abort(400)
    event = request.get_json()
    return "", 200

Answering and retries

  • A 2xx answer within 10 seconds counts as delivered. Answer first and do slow work afterwards.
  • Redirects are not followed; a 3xx counts as a failure.
  • A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours: seven attempts over about 21 hours. Then it is marked failed.
  • After 20 deliveries in a row that failed for good, the endpoint is disabled and nothing more is sent to it. Fix it, then turn it back on in the panel or with PATCH {"status": "active"}.
  • Retries mean an event can arrive twice, and a later event can arrive before an earlier one. Ignore ids you have seen, and trust the status and updated_at in data over arrival order.

The last 20 deliveries of each endpoint, with response code and timing, are in the panel and in GET /v1/webhooks/{id}.

The endpoint object

NameTypeDescription
idintegerEndpoint ID.
objectstringAlways webhook_endpoint.
urlstringWhere deliveries go.
eventsarrayThe events it receives. Every event when you didn’t pick any.
statusstringactive or disabled.
livemodebooleanfalse for a sandbox endpoint.
consecutive_failuresintegerDeliveries in a row that failed for good. At 20 the endpoint is disabled.
last_success_at, last_failure_at, created_atstring or nullISO 8601 times in UTC.
secretstringOnly when you add an endpoint or replace its secret.

List endpoints

GET/v1/webhooksScope webhooks

The endpoints of the key’s environment, oldest first.

Request
curl https://orbismm.com/api/v1/webhooks \
  -H "Authorization: Bearer $ORBISMM_KEY"

Response · 200 OK
{
  "data": [
    {
      "id": 31,
      "object": "webhook_endpoint",
      "url": "https://hooks.yourpanel.example/orbismm",
      "events": [
        "order.status_changed",
        "refill.completed",
        "refill.rejected"
      ],
      "status": "active",
      "livemode": true,
      "consecutive_failures": 0,
      "last_success_at": "2026-09-26T16:47:31Z",
      "last_failure_at": null,
      "created_at": "2026-09-20T10:12:05Z"
    }
  ]
}

Add an endpoint

POST/v1/webhooksScope webhooks

Body

NameTypeRequiredDescription
urlstringrequiredPublic HTTPS URL, up to 500 characters.
eventsarrayoptionalEvents to receive. Leave it out for every event.

Errors: 422 invalid_url for a URL we won’t call, 422 limit_reached at 10 endpoints, 422 invalid_request for an unknown event name.

Request
curl https://orbismm.com/api/v1/webhooks \
  -H "Authorization: Bearer $ORBISMM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.yourpanel.example/orbismm","events":["order.status_changed","refill.completed","refill.rejected"]}'

Response · 201 Created: copy the secret now, the API shows it only here
{
  "id": 31,
  "object": "webhook_endpoint",
  "url": "https://hooks.yourpanel.example/orbismm",
  "events": [
    "order.status_changed",
    "refill.completed",
    "refill.rejected"
  ],
  "status": "active",
  "livemode": true,
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_at": "2026-09-20T10:12:05Z",
  "secret": "whsec_7Qm2Lx9TzR4vKc8NbW1yHs6FpD3gJe5AaU0oIi2Z"
}

Get an endpoint

GET/v1/webhooks/{id}Scope webhooks

The endpoint and its 20 most recent deliveries, newest first.

A delivery’s status is pending (queued or waiting for a retry at next_attempt_at), delivered or failed.

Request
curl https://orbismm.com/api/v1/webhooks/31 \
  -H "Authorization: Bearer $ORBISMM_KEY"

Response · 200 OK
{
  "id": 31,
  "object": "webhook_endpoint",
  "url": "https://hooks.yourpanel.example/orbismm",
  "events": [
    "order.status_changed",
    "refill.completed",
    "refill.rejected"
  ],
  "status": "active",
  "livemode": true,
  "consecutive_failures": 0,
  "last_success_at": "2026-09-26T16:47:31Z",
  "last_failure_at": null,
  "created_at": "2026-09-20T10:12:05Z",
  "recent_deliveries": [
    {
      "id": 7790,
      "event": "order.status_changed",
      "event_id": "evt_01926e2b-4c1e-7f10-8a44-6d2b9c3e7a05",
      "status": "delivered",
      "attempts": 1,
      "response_code": 200,
      "duration_ms": 142,
      "summary": "#58213 → completed",
      "created_at": "2026-09-26T16:47:30Z",
      "delivered_at": "2026-09-26T16:47:31Z",
      "next_attempt_at": null
    }
  ]
}

Change an endpoint

PATCH/v1/webhooks/{id}Scope webhooks

Send only what changes. Setting status back to active also resets consecutive_failures.

Body

NameTypeDescription
urlstringA new public HTTPS URL.
eventsarray or nullA new list of events, or null for every event.
statusstringactive or disabled.
Request
curl -X PATCH https://orbismm.com/api/v1/webhooks/31 \
  -H "Authorization: Bearer $ORBISMM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "active"}'

Response · 200 OK: the endpoint object

Delete an endpoint

DELETE/v1/webhooks/{id}Scope webhooks
Request
curl -X DELETE https://orbismm.com/api/v1/webhooks/31 \
  -H "Authorization: Bearer $ORBISMM_KEY"

Response · 200 OK
{
  "deleted": true
}

Send a test event

POST/v1/webhooks/{id}/testScope webhooks

Queues a signed ping event to the endpoint, with {"message": "Webhook endpoint reachable"} as its data. Follow the result in the endpoint’s deliveries.

Request
curl -X POST https://orbismm.com/api/v1/webhooks/31/test \
  -H "Authorization: Bearer $ORBISMM_KEY"

Response · 202 Accepted
{
  "delivery": 7791,
  "event": "ping",
  "status": "queued"
}

Replace the secret

POST/v1/webhooks/{id}/rotate-secretScope webhooks

Issues a new signing secret and returns it with the endpoint. From now on every delivery, retries included, is signed with the new secret, so update your server right away.

Request
curl -X POST https://orbismm.com/api/v1/webhooks/31/rotate-secret \
  -H "Authorization: Bearer $ORBISMM_KEY"

Response · 200 OK: the endpoint object, plus
{
  "secret": "whsec_Vn3Hq8Ry1Kd6Tb0Xw4Mz9Lc2Pf7Gs5Ej3Ua8Io1B"
}