# Webhooks

> Receive a callback when a background response finishes instead of polling for it.

Source: https://sference.com/docs/guides/webhooks

When a request can take a while to finish, you do not have to poll `GET /v1/responses/{response_id}` in a loop. Attach a callback URL to the request and sference delivers the finished result to your server as an HTTP POST.

The feature works on `POST /v1/responses` and `POST /v1/chat/completions`, and is configured entirely through two `metadata` keys.

> **Beta**
>
> Webhook delivery is in beta and enabled per account. If you set the metadata keys and no callback arrives, check with your account team — the request itself is never affected by webhook configuration.

## Parameters

Both keys live inside the `metadata` object on the create request.

| Key | Type | Meaning |
| --- | --- | --- |
| `metadata.completion_webhook` | string | Destination URL for the callback. Must be `http` or `https`. If omitted or unusable, no callback is sent and the request is unaffected. |
| `metadata.webhook_token` | string | Optional shared secret. sference sends its value as `Authorization: Bearer <token>` so your endpoint can verify the call is genuine. |

## Set up an endpoint

### 1. Expose a route that accepts POST

Your endpoint receives a JSON body and should respond as soon as it has accepted the payload. The body matches what `GET /v1/responses/{response_id}` returns for the same request, whether the response completed or failed.

```python
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/hooks/inference-done")
async def inference_done(request: Request):
    payload = await request.json()
    # payload is the same object GET /v1/responses/{id} returns
    enqueue_for_processing(payload["id"], payload)
    return {"ok": True}
```

### 2. Verify the Authorization header

If you set `webhook_token`, reject any call whose header does not match:

```python
from fastapi import Header, HTTPException

WEBHOOK_TOKEN = "whk_3f0a9c2e7b14"

def assert_authorized(authorization: str = Header(default="")):
    if authorization != f"Bearer {WEBHOOK_TOKEN}":
        raise HTTPException(status_code=401)
```

### 3. Submit a request that points at your endpoint

Set `background: true` and add the callback URL (and token, if you use one) to `metadata`:

```python
from openai import OpenAI

client = OpenAI(base_url="https://api.sference.com/v1", api_key=SFERENCE_KEY)

response = client.responses.create(
    model="zai-org/GLM-5.3",
    input="Summarize this document.",
    background=True,
    metadata={
        "completion_webhook": "https://app.example.com/hooks/inference-done",
        "webhook_token": "whk_3f0a9c2e7b14",
    },
)
```

## What sference sends

A single POST request.

| Part | Value |
| --- | --- |
| Method | `POST` to the URL in `completion_webhook` |
| `Content-Type` | `application/json` |
| `Authorization` | Present only when `webhook_token` was set: `Bearer <webhook_token>` |
| Body | The full response object, identical to `GET /v1/responses/{response_id}` — including `status` and, for failures, the `error` envelope. The response `id` is in the body and is your deduplication key. |

## When a callback fires

sference POSTs to your endpoint once a response reaches a terminal status:

- **Completed** — the request finished normally. The body is the completed response object; for a Chat Completions request, the chat completion.
- **Failed** — the body is the failed response object, including `error.code` and `error.message`. This includes a background request that did not finish before its deadline: it arrives with `status: "failed"` and `error.code: "timeout"`. Branch on `status` in your handler.

## Delivery semantics

> **Callbacks can arrive more than once**
>
> Treat the response `id` in the body as an idempotency key and ignore any `id` you have already processed.

- **Retries.** A delivery that returns a non-2xx status or hits a network error is retried up to **3 times**. Return a **2xx** as soon as you accept the payload to stop the retries.
- **Timeout.** Each attempt has a **30 second** ceiling. A timeout counts as a failed attempt and triggers the next retry.
- **Best effort.** If every attempt fails, the failure is logged but never affects the response record or the API. The result stays available through `GET /v1/responses/{response_id}` regardless of whether delivery ever succeeded.

Because delivery is best-effort, a webhook is an optimization over polling, not a replacement for having the result retrievable. Keep the retrieval path in your code as the source of truth.

## Test it locally

Point a real request at a listener on your own machine.

### 1. Run a listener that prints the body and returns 200

```bash
python -c "
from http.server import HTTPServer, BaseHTTPRequestHandler; import json
class H(BaseHTTPRequestHandler):
 def do_POST(self):
  print(json.dumps(json.loads(self.rfile.read(int(self.headers['Content-Length']))), indent=2))
  self.send_response(200); self.end_headers()
HTTPServer(('127.0.0.1', 8765), H).serve_forever()
"
```

### 2. Tunnel to it

```bash
ngrok http 8765
```

### 3. Use the tunnel URL as `completion_webhook`

Paste the `https://…ngrok-free.app` URL from ngrok into the `metadata` block above and submit the request. The listener prints the payload when the response finishes.

- [Requests at scale](https://sference.com/docs/guides/requests-at-scale): When to fan out with webhooks vs. submit a batch.
- [OpenAI Responses API](https://sference.com/docs/api/responses): Background responses, statuses, and the events API.
- [Max queue wait](https://sference.com/docs/guides/max-queue-wait): Fail fast instead of waiting on a congested fleet.
