Marble 2 beta

Webhooks

Instead of polling an operation, ask Marble to call your server when it finishes. Put a webhookUrl on any task request and Marble POSTs one signed event to it when the operation reaches SUCCEEDED or FAILED, retrying for up to 24 hours until your server acknowledges it.

Webhooks are a Developer API feature: they apply to tasks submitted with an API key.

Request a callback

Add webhookUrl to the task body. The URL must be https on port 443, name a public DNS host (not an IP address), and carry no credentials or fragment. Anything you need to correlate the callback with your own records can ride the query string; it is echoed back exactly and is not covered by the signature.

bash
curl --request POST "$WLT_API_BASE_URL/tasks:atlasTextToImage" \  --header "WLT-Api-Key: $WLT_API_KEY" \  --header "Content-Type: application/json" \  --data '{    "prompt": "a cozy, sunlit living room",    "webhookUrl": "https://hooks.example.com/marble?job=42"  }'

The response is the same Operation a request without a callback returns. The accepted destination is read back on the operation's webhookDelivery record, described below. A request selects one outcome channel: webhookUrl cannot be combined with ?wait=true.

Receive the event

Your server receives one POST per completion with a JSON body:

json
{  "id": "evt_op_01jexample000000000000000000",  "type": "task.succeeded",  "schemaVersion": "1",  "createdAt": "2026-09-19T12:00:00Z",  "data": {    "accountId": "acct_example",    "projectId": "project_example",    "operationId": "op_01jexample000000000000000000",    "operation": "accounts/acct_example/projects/project_example/operations/op_01jexample000000000000000000",    "taskName": "atlasTextToImage",    "state": "SUCCEEDED"  }}
FieldMeaning
typetask.succeeded or task.failed. Cancelled operations send no event. An operation that expires also sends task.failed, with data.state FAILED.
createdAtWhen the operation finished, not when this attempt was sent.
dataThe operation's IDs and task name. task.failed adds data.error.code, the operation's error code.

The event is deliberately thin. It carries no inputs, results, or asset URLs. Fetch the result with GET /api/v2/operations/{operationId} using a project API key, exactly as you would after polling; receiving an event grants no access on its own.

Answer with any 2xx within 15 seconds. Do the work after acknowledging: a slow handler is retried as if it had failed.

Verify the signature

Every request carries three headers following the Standard Webhooks specification:

HeaderValue
webhook-idThe delivery ID (del_...), the same on every retry of one event. Use it to deduplicate.
webhook-timestampUnix seconds when this attempt was sent. Reject attempts older than a few minutes, or a captured request stays replayable.
webhook-signatureOne or more space-separated v1a,<base64> values: Ed25519 signatures over {webhook-id}.{webhook-timestamp}.{raw body}.

Marble signs every callback with its own key and publishes the public keys as a JWKS at https://api.atlas-beta.worldlabs.ai/.well-known/webhooks/jwks.json (the host of your API base URL; keys are kty: OKP, crv: Ed25519). There is no secret to generate or store. Fetch the document, cache it for up to 24 hours, and accept the request if any signature verifies against any listed key: the header does not say which key signed. When none verifies, refetch the JWKS once before rejecting, since a key may have rotated. Any Standard Webhooks library that supports the asymmetric v1a scheme verifies these headers as-is.

python
import base64, json, time, urllib.requestfrom cryptography.exceptions import InvalidSignaturefrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
JWKS_URL = "https://api.atlas-beta.worldlabs.ai/.well-known/webhooks/jwks.json"TOLERANCE_SECS = 300
def public_keys() -> list[Ed25519PublicKey]:    with urllib.request.urlopen(JWKS_URL) as response:        document = json.load(response)  # cache this for up to 24 hours    keys = []    for key in document["keys"]:        if key.get("kty") != "OKP" or key.get("crv") != "Ed25519":            continue        raw = base64.urlsafe_b64decode(key["x"] + "=" * (-len(key["x"]) % 4))        keys.append(Ed25519PublicKey.from_public_bytes(raw))    return keys
def verify(keys: list[Ed25519PublicKey], headers: dict, body: bytes) -> bool:    timestamp = headers["webhook-timestamp"]    try:        sent_at = int(timestamp)    except ValueError:        return False    if abs(time.time() - sent_at) > TOLERANCE_SECS:        return False    signed = f"{headers['webhook-id']}.{timestamp}.".encode() + body    for part in headers["webhook-signature"].split():        if not part.startswith("v1a,"):            continue        try:            signature = base64.b64decode(part[4:], validate=True)        except ValueError:            continue        for key in keys:            try:                key.verify(signature, signed)                return True            except (InvalidSignature, ValueError):                pass    return False

When Marble rotates its key, every callback carries a signature from both the new and the old key and the JWKS lists both for at least 24 hours, so a receiver holding either key keeps verifying.

One key signs every account's callbacks, so a valid signature proves the event came from Marble, not that it was meant for your receiver. If staging and production receivers must not act on each other's callbacks, have each receiver check data.projectId against the project it serves and drop anything else. An event carries no results, so a callback that reaches the wrong receiver can only prompt a read of an operation that receiver's API key cannot see.

Retries and states

A 2xx marks the delivery DELIVERED. A 410 Gone marks it SUPPRESSED with no further attempts. A webhookUrl whose host resolves to a private or internal address is marked EXHAUSTED after its first attempt, since Marble never sends to such an address. A first send that fails with a 5xx, a timeout, a connection or TLS error, or a host that does not resolve yet gets one fast retry about 2 seconds later, skipped when the response carries a valid Retry-After header. Those failures, and any other answer that is not a 2xx or 410 (including a 4xx and a redirect: Marble does not follow redirects), are retried with growing delays (30 seconds doubling to 4 hours, ±10% jitter) until 24 hours after the operation finished, then marked EXHAUSTED. A Retry-After header on any retried response is honored when it asks for a later time than the schedule.

Read the delivery record for any operation that asked for a callback:

bash
curl "$WLT_API_BASE_URL/operations/${WLT_OPERATION_ID}/webhookDelivery" \  --header "WLT-Api-Key: $WLT_API_KEY"
StateMeaning
SCHEDULEDThe operation is not finished yet, or its completion is on its way to the webhook service.
PUBLISH_FAILEDMarble could not hand the completion to its webhook service. Poll the operation instead.
PENDINGWaiting for the next attempt; nextAttemptTime says when.
IN_FLIGHTAn attempt is running.
DELIVEREDYour receiver acknowledged the event.
SUPPRESSEDYour receiver answered 410.
EXHAUSTEDThe 24-hour window closed without an acknowledgement, the webhookUrl host resolves to a private address and no attempt can succeed, or the operation's content was purged.

The record also carries attemptCount, event (the exact body sent), and lastAttempt, whose httpStatus or errorCategory describes the most recent send or, if nothing was ever sent, why the delivery closed. Records are kept for 90 days. Purging an operation's content ends a delivery still waiting to be sent (EXHAUSTED, no further attempts), and the record stays without the destination. An operation that never asked for a callback answers 404, and so does a cancelled one: a cancel sends no event.

To check many operations at once, list them: operations that asked for a callback may carry a webhookDelivery summary with state, attemptCount, and lastAttemptTime. It is absent after cancellation or when the webhook service cannot be read.

Test your receiver

There is no endpoint to send a test event. Submit a real task with webhookUrl set to your receiver and use that delivery to confirm your handler and signature verification work.