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.
Events
Section titled “Events”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 iscompleted.datais the job, the same asGET /v1/jobs/{id}returns, with signed URLs inoutputs.job.failed: a job isfailedorexpired.datais the job, anddata.statussays which of the two. For afailedjob,data.errorsays why. For anexpiredjob,data.errorisnull. A job that the model provider rejects because of its content also ends here, withdata.error.codeset tocontent_policy_violation.job.cancelled: a job iscancelled.datais the job.deposit.credited: a deposit was credited to your balance.datahas 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.datahas 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.
The event body
Section titled “The event body”Every delivery has a JSON body with four fields:
id: the ID of the event. It is also the value of thewebhook-idheader.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.
Low balance alerts
Section titled “Low balance alerts”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.
Create an endpoint
Section titled “Create an endpoint”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.
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" ]}'const response = await fetch("https://api.sakuranbo.ai/v1/webhook-endpoints", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.SAKURANBO_API_KEY}`, }, body: JSON.stringify({ "url": "https://example.com/webhooks/cherry", "events": [ "job.succeeded", "job.failed", "job.cancelled" ] }),});const data = await response.json();console.log(data);import os
import requests
response = requests.post( "https://api.sakuranbo.ai/v1/webhook-endpoints", headers={ "Authorization": f"Bearer {os.environ['SAKURANBO_API_KEY']}", }, json={ "url": "https://example.com/webhooks/cherry", "events": [ "job.succeeded", "job.failed", "job.cancelled", ], },)print(response.json())The request body has these fields:
| Name | Type | Required | Default | Constraints |
|---|---|---|---|---|
url | string | Yes | 1 to 2,048 characters | |
events | string[] | No | One of: job.succeeded, job.failed, job.cancelled, deposit.credited, balance.low; At least 1 item | |
description | string | No | Up 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
httpsand 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.
How deliveries work
Section titled “How deliveries work”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
2xxstatus within 15 seconds means the event was received. Do the slow work after you answer. - Sakuranbo AI does not follow redirects. A
3xxstatus counts as a failure. - A
410 Gonestatus stops the endpoint. Events that happen while the endpoint is stopped are not queued, and resuming does not send them again. To resume, callPATCH /v1/webhook-endpoints/{id}withenabledset totrue, 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-idand the same body, so store the IDs that you have processed and skip a repeat. - The order of events is not guaranteed. Use
timestampand the state indata. - Return
2xxfor event types that you do not handle, so they are not retried.
Verify the signature
Section titled “Verify the signature”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.
- Read the
webhook-id,webhook-timestamp, andwebhook-signatureheaders. Reject the request if one is missing. - Check that the timestamp is within 5 minutes of the current time, in the past or in the future. This stops replayed requests.
- 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. - 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.
- Compute the HMAC-SHA256 of the signed content with the key, and encode the result as standard base64.
- 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. - 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 base64import hashlibimport hmacimport jsonimport 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
Section titled “Rotate the secret”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.
Send a test event
Section titled “Send a test event”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.
curl -X POST "https://api.sakuranbo.ai/v1/webhook-endpoints/we_0123456789abcdef0123456789abcdef/test" \ -H "Authorization: Bearer $SAKURANBO_API_KEY"const response = await fetch("https://api.sakuranbo.ai/v1/webhook-endpoints/we_0123456789abcdef0123456789abcdef/test", { method: "POST", headers: { Authorization: `Bearer ${process.env.SAKURANBO_API_KEY}`, },});const data = await response.json();console.log(data);import os
import requests
response = requests.post( "https://api.sakuranbo.ai/v1/webhook-endpoints/we_0123456789abcdef0123456789abcdef/test", headers={ "Authorization": f"Bearer {os.environ['SAKURANBO_API_KEY']}", },)print(response.json())Delivery log
Section titled “Delivery log”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.
Management API
Section titled “Management API”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.
Errors
Section titled “Errors”| Code | HTTP status | Type | Description |
|---|---|---|---|
invalid_request | 400 | invalid_request_error | The request is malformed or a value is out of range. details.allowed lists the valid values when they are known. |
unauthorized | 401 | authentication_error | The API key is missing, malformed, unknown, revoked, or expired. The response does not say which. |
webhooks_unavailable | 503 | api_error | Webhooks 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.
All prices are in USD. Listed prices include our margin.