Skip to content

Webhooks

A webhook sends an HTTP request from Sakuranbo AI to your server when something happens in your account. Use webhooks to learn that an image or video job has finished without polling it, to record deposits as they arrive, and to get a warning when your balance is low.

Deliveries follow the Standard Webhooks specification, and each one is signed. Your server must verify the signature before it trusts a request. See Verify the signature.

An endpoint can subscribe to these events: job.succeeded, job.failed, job.cancelled, deposit.credited, balance.low. webhook.test is also sent, but only when you ask for a test event, and you cannot subscribe to it.

  • job.succeeded: an image or video job is completed. data is the job, the same as GET /v1/jobs/{id} returns, with signed URLs in outputs.
  • job.failed: a job is failed or expired. data is the job, and data.status says which of the two. For a failed job, data.error says why. For an expired job, data.error is null. A job that the model provider rejects because of its content also ends here, with data.error.code set to content_policy_violation.
  • job.cancelled: a job is cancelled. data is the job.
  • deposit.credited: a deposit was credited to your balance. data has the amount, the dates, and your balance.
  • balance.low: your available balance fell below the alert level that you set in the settings of the dashboard. data has the available balance and the alert level.
  • webhook.test: a test event that you asked for.

See Jobs for the job statuses and Balance and key limits for the balance.

Every delivery has a JSON body with four fields:

  • id: the ID of the event. It is also the value of the webhook-id header.
  • type: the event type.
  • timestamp: when the event happened, as an ISO 8601 time in UTC.
  • data: the content of the event, which depends on the type.

Ignore event types and fields that you do not know. Sakuranbo AI can add new events and new fields without notice.

Set the alert level in the settings of the dashboard. A balance.low event is sent each time the available balance goes from the alert level or above to below it. Another event is sent only after the balance has come back to the alert level or above. A charge, an expiring deposit, or a downward adjustment can trigger it. A reservation alone does not. Nothing is sent while no alert level is set.

An endpoint is the URL that receives the events. You can create one in the dashboard or with the API.

In the dashboard, open the Webhooks page. You can create endpoints, edit them, stop and resume them, send test events, rotate the secret, and read the delivery log.

With the API, call POST /v1/webhook-endpoints.

Terminal window
curl -X POST "https://api.sakuranbo.ai/v1/webhook-endpoints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SAKURANBO_API_KEY" \
-d '{
"url": "https://example.com/webhooks/cherry",
"events": [
"job.succeeded",
"job.failed",
"job.cancelled"
]
}'

The request body has these fields:

NameTypeRequiredDefaultConstraints
urlstringYes1 to 2,048 characters
eventsstring[]NoOne of: job.succeeded, job.failed, job.cancelled, deposit.credited, balance.low; At least 1 item
descriptionstringNoUp to 256 characters

If you leave out events, the endpoint subscribes to all the event types that exist when you create it. An event type that is added later is not added to the endpoint. To receive it, call PATCH /v1/webhook-endpoints/{id} with the full list of event types in events, including the new one. events replaces the whole list.

The response has the ID of the endpoint and the signing secret. The secret starts with whsec_. The full secret appears only in the response to this request and in the response to a secret rotation. You cannot read it again later, so store it right away. If you lose it, rotate the secret.

The URL must follow these rules:

  • It uses https and the default port.
  • Its host is a public DNS name. IP addresses, localhost, names without a dot, and internal names are rejected. A URL with a user name or password is rejected.
  • It can have a path and a query string.

A URL that breaks a rule returns 400 with invalid_request. error.param is url, and error.details.reason says which rule failed. To test on your own machine, expose it through a public HTTPS tunnel.

An account can have up to 10 endpoints. Delete one to add another.

Sakuranbo AI sends each event as a POST request with the JSON body and these headers:

  • webhook-id: the ID of the event.
  • webhook-timestamp: the time of this attempt, in Unix seconds.
  • webhook-signature: the signature. See Verify the signature.

Your server answers with these rules:

  • A 2xx status within 15 seconds means the event was received. Do the slow work after you answer.
  • Sakuranbo AI does not follow redirects. A 3xx status counts as a failure.
  • A 410 Gone status stops the endpoint. Events that happen while the endpoint is stopped are not queued, and resuming does not send them again. To resume, call PATCH /v1/webhook-endpoints/{id} with enabled set to true, or use the dashboard. Then retry the failed deliveries by hand.
  • Any other status, a timeout, or a connection failure counts as a failure. Sakuranbo AI tries again, up to 8 attempts in total over about 31 hours. After the last attempt the delivery is failed.

Write your receiver with these facts in mind:

  • An event can arrive more than once. Every attempt of one event has the same webhook-id and the same body, so store the IDs that you have processed and skip a repeat.
  • The order of events is not guaranteed. Use timestamp and the state in data.
  • Return 2xx for event types that you do not handle, so they are not retried.

Each delivery is signed with the secret of the endpoint. Verify the signature to be sure that Sakuranbo AI sent the request and that nobody changed it. The scheme is Standard Webhooks version v1, which uses HMAC-SHA256.

  1. Read the webhook-id, webhook-timestamp, and webhook-signature headers. Reject the request if one is missing.
  2. Check that the timestamp is within 5 minutes of the current time, in the past or in the future. This stops replayed requests.
  3. Make the key. Remove the whsec_ prefix from the secret and decode the rest from base64. The key is the decoded bytes, not the text of the secret.
  4. Make the signed content: the value of the ID header, a dot, the value of the timestamp header, a dot, and the raw request body.
  5. Compute the HMAC-SHA256 of the signed content with the key, and encode the result as standard base64.
  6. The signature header has one or more entries separated by spaces. Each entry is a version, a comma, and a signature. Skip entries whose version is not v1. The request is valid if any entry matches your result. Compare in constant time.
  7. Parse the JSON only after the signature is valid.

Verify the exact bytes that you received. A web framework that parses the JSON and then serializes it again can change the bytes, and then the signature does not match. Read the raw body before any parser runs.

TypeScript:

import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Verifies a Sakuranbo AI webhook (Standard Webhooks) and returns the parsed event.
* rawBody must be the request body exactly as received, before any JSON parsing.
*/
export function verifyWebhook(
secret: string, // "whsec_..." as shown when the endpoint was created or its secret rotated
rawBody: string,
headers: Record<string, string | undefined>, // header names in lower case
toleranceSeconds = 300,
) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) throw new Error("missing webhook headers");
const sentAt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > toleranceSeconds) {
throw new Error("timestamp outside the tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
// During a secret rotation the header holds two signatures, separated by a space.
for (const entry of signatures.split(" ")) {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) continue;
const received = Buffer.from(signature, "base64");
if (received.length === expected.length && timingSafeEqual(received, expected)) {
return JSON.parse(rawBody);
}
}
throw new Error("no matching signature");
}

Python:

import base64
import hashlib
import hmac
import json
import time
def verify_webhook(secret: str, raw_body: bytes, headers: dict, tolerance_seconds: int = 300):
"""Verify a Sakuranbo AI webhook (Standard Webhooks) and return the parsed event.
raw_body must be the request body exactly as received, before any JSON parsing.
"""
headers = {name.lower(): value for name, value in headers.items()}
message_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (message_id and timestamp and signatures):
raise ValueError("missing webhook headers")
try:
sent_at = int(timestamp)
except ValueError:
raise ValueError("invalid timestamp") from None
if abs(int(time.time()) - sent_at) > tolerance_seconds:
raise ValueError("timestamp outside the tolerance")
if secret.startswith("whsec_"):
secret = secret[len("whsec_"):]
key = base64.b64decode(secret)
signed = f"{message_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
# During a secret rotation the header holds two signatures, separated by a space.
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version != "v1" or not signature:
continue
if hmac.compare_digest(base64.b64decode(signature), expected):
return json.loads(raw_body)
raise ValueError("no matching signature")

The Standard Webhooks libraries do the same checks. Use the standardwebhooks package from npm or from PyPI if you prefer a library to your own code.

Rotate the secret if you lost it or if it might have leaked. Use the dashboard or POST /v1/webhook-endpoints/{id}/rotate-secret. The response has the new secret, and this is the only time you can read it.

For 24 hours after a rotation, the signature header has two signatures, the new one first and the previous one second. A receiver that has either secret can verify the request during that time. Switch your server to the new secret. The old secret stops working when that time is over. previous_secret_expires_at on the endpoint shows when that happens.

If you rotate again during that time, the secret from before the first rotation stops working at once. Only the two newest secrets sign deliveries.

POST /v1/webhook-endpoints/{id}/test sends a webhook.test event to one endpoint, whatever events the endpoint subscribes to. The endpoint must be enabled. The response is the new delivery with the status pending. The dashboard has a button for the same action.

Terminal window
curl -X POST "https://api.sakuranbo.ai/v1/webhook-endpoints/we_0123456789abcdef0123456789abcdef/test" \
-H "Authorization: Bearer $SAKURANBO_API_KEY"

GET /v1/webhook-endpoints/{id}/deliveries and the dashboard show every delivery of an endpoint, newest first. Each entry has the status, the number of attempts, the last HTTP status, the time of the next attempt, and the first part of the response from your server. The log does not include the body of the event. Sakuranbo AI keeps the log for 30 days.

To send a delivery again, call POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry or use the dashboard. The delivery goes out with the same event ID and the same body, and it starts a new series of attempts. You can retry only a delivery that has finished, either succeeded or failed, and the endpoint must be enabled.

All of these endpoints need an API key. See Authentication.

  • POST /v1/webhook-endpoints: create an endpoint.
  • GET /v1/webhook-endpoints: list your endpoints.
  • GET /v1/webhook-endpoints/{id}: read one endpoint.
  • PATCH /v1/webhook-endpoints/{id}: change the URL, the events, the description, or whether the endpoint is enabled.
  • DELETE /v1/webhook-endpoints/{id}: delete an endpoint and its delivery log.
  • POST /v1/webhook-endpoints/{id}/test: send a test event.
  • POST /v1/webhook-endpoints/{id}/rotate-secret: create a new secret.
  • GET /v1/webhook-endpoints/{id}/deliveries: read the delivery log.
  • POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry: send a delivery again.
CodeHTTP statusTypeDescription
invalid_request400invalid_request_errorThe request is malformed or a value is out of range. details.allowed lists the valid values when they are known.
unauthorized401authentication_errorThe API key is missing, malformed, unknown, revoked, or expired. The response does not say which.
webhooks_unavailable503api_errorWebhooks are not available in this environment yet. Retry later.

The other endpoints return 404 with not_found when the endpoint or the delivery does not exist in your account. A test event or a retry for a stopped endpoint returns 400 with invalid_request. For the full list of codes, see Errors.