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
- Build a route on your server that accepts a JSON
POSTand answers2xxquickly. - 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. - Store the signing secret (
whsec_…) and verify every request with it. - 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
| Event | When | data |
|---|---|---|
order.status_changed | Any change of an order’s status. | Order |
order.started | Delivery started (in_progress). | Order |
order.completed | Delivered in full. | Order |
order.partial | Closed with part undelivered and refunded. | Order |
order.canceled | Closed before delivery and refunded. | Order |
refill.completed | A refill finished. | Refill |
refill.rejected | The supplier refused a refill. | Refill |
service.changed | A price change on any service was announced or applied, or a service was paused or resumed. Live endpoints only. | Announcement |
balance.low | Your 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).
| Name | Type | Description |
|---|---|---|
id | string | Event ID, evt_…. The same on every retry of one delivery: use it to ignore duplicates. |
type | string | The event, for example order.completed. |
created_at | string | When the event happened, ISO 8601 in UTC. |
livemode | boolean | false for sandbox events. |
data | object | See 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-Type | application/json |
User-Agent | Orbismm-Webhooks/1.0 |
Orbismm-Event | The event type, for example order.completed. |
Orbismm-Delivery | Delivery ID, the same on every retry. It matches id in the endpoint’s recent_deliveries. |
Orbismm-Signature | t=<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.
- Split the header on commas and take
tandv1. - Reject the request if
tis more than 5 minutes from your clock. That stops replays. - 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. - Compare with
v1in 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 "", 200Answering and retries
- A
2xxanswer within 10 seconds counts as delivered. Answer first and do slow work afterwards. - Redirects are not followed; a
3xxcounts 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 thestatusandupdated_atindataover 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
| Name | Type | Description |
|---|---|---|
id | integer | Endpoint ID. |
object | string | Always webhook_endpoint. |
url | string | Where deliveries go. |
events | array | The events it receives. Every event when you didn’t pick any. |
status | string | active or disabled. |
livemode | boolean | false for a sandbox endpoint. |
consecutive_failures | integer | Deliveries in a row that failed for good. At 20 the endpoint is disabled. |
last_success_at, last_failure_at, created_at | string or null | ISO 8601 times in UTC. |
secret | string | Only when you add an endpoint or replace its secret. |
List endpoints
GET/v1/webhooksScope webhooksThe 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 webhooksBody
| Name | Type | Required | Description |
|---|---|---|---|
url | string | required | Public HTTPS URL, up to 500 characters. |
events | array | optional | Events 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 webhooksThe 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 webhooksSend only what changes. Setting status back to active also resets consecutive_failures.
Body
| Name | Type | Description |
|---|---|---|
url | string | A new public HTTPS URL. |
events | array or null | A new list of events, or null for every event. |
status | string | active 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 objectDelete an endpoint
DELETE/v1/webhooks/{id}Scope webhooksRequest
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 webhooksQueues 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 webhooksIssues 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"
}