> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundpiece.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get pushed the result the moment an operation completes — no polling required

## Overview

Supply a `callback_url` on the original `PUT` and we'll `POST` the final response to that URL when the operation reaches `ready` or `failed`. This eliminates polling latency for production integrations.

Webhooks follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification, so any standards-compliant client library can verify them out of the box.

<Note>
  **Polling is canonical.** Webhooks are a best-effort latency optimisation; if you didn't receive one within a reasonable window, fall back to polling the `GET` endpoint. Don't rely on webhook receipt as proof of operation state.
</Note>

***

## Registering a callback

<Warning>
  **An active signing secret is required.** Every webhook is signed, so we only deliver callbacks for accounts that have at least one active [signing secret](/authentication#creating-a-webhook-signing-secret). With no secret there is nothing to sign with, and **no callbacks are sent at all** — even if you supply a `callback_url`. Create one in the dashboard before relying on webhooks.
</Warning>

Add `callback_url` to any `PUT` request body:

```json theme={null}
{
  "idempotency_key": "550e8400-e29b-41d4-a716-446655440000",
  "lyrics": "...",
  "prompt": "...",
  "callback_url": "https://your-app.example.com/webhooks/soundpiece"
}
```

Your endpoint must:

* Be publicly accessible over **HTTPS** with a valid TLS certificate (self-signed certs are rejected).
* Return a **2xx** status code within **10 seconds**.
* Handle **duplicate deliveries** idempotently — use the `webhook-id` header as your deduplication key.

We re-verify the URL against the same rules at delivery time, so DNS rebinding to a private IP between submission and delivery is rejected too.

***

## Payload shape

The body is the **same DTO you'd get from polling**, wrapped:

```json theme={null}
{
  "type": "create.new_song.ready",
  "data": {
    "id": "op_01h7k...",
    "status": "ready",
    "created_at": "2026-05-20T12:34:56Z",
    "idempotency_key": "550e8400-e29b-41d4-a716-446655440000",
    "lyrics": "...",
    "prompt": "...",
    "reference": null,
    "outputs": [
      {
        "url": "https://cdn.soundpiece.co/...signed...",
        "expires_at": "2026-05-20T12:50:21Z",
        "duration": 87.4
      }
    ],
    "error": null
  }
}
```

`type` is `<route>.<status>` — either `.ready` or `.failed` — for fan-out routing on your side.

The `data` field is exactly the DTO from the matching `GET` endpoint. Anything you can do with a polled response, you can do with a webhook payload.

Correlate webhook deliveries with your own records using the **echoed `idempotency_key`** — you chose it on the `PUT`, so you already have it on your side.

***

## Signature verification

Every delivery includes three headers (per the [Standard Webhooks](https://www.standardwebhooks.com/) spec):

| Header              | Value                                                                                                                          |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `webhook-id`        | A stable id for this event. Retries reuse the same id, so consumers dedupe on it.                                              |
| `webhook-timestamp` | Unix seconds at first delivery attempt.                                                                                        |
| `webhook-signature` | `v1,<base64(HMAC-SHA256(secret, "<id>.<timestamp>.<body>"))>`. Multiple signatures are space-separated during secret rotation. |

To verify a webhook:

1. Compute `HMAC-SHA256(your_signing_secret, "<webhook-id>.<webhook-timestamp>.<raw-body>")`.
2. Base64-encode it.
3. Compare against the `v1,…` signature in `webhook-signature` using **constant-time comparison**.
4. Reject if the signature doesn't match, **or** if `webhook-timestamp` is more than 5 minutes from the current time.

Your webhook signing secret is a separate resource from your API key — you create them independently in the dashboard. The plaintext (`whsec_…`) is shown once on creation; we store only an encrypted copy. See [authentication](/authentication).

### Using the Standard Webhooks library (recommended)

There are official libraries that hide the HMAC math. From [github.com/standard-webhooks/standard-webhooks](https://github.com/standard-webhooks/standard-webhooks):

<CodeGroup>
  ```python Python theme={null}
  # pip install standardwebhooks
  from standardwebhooks import Webhook
  from flask import Flask, request, abort

  app = Flask(__name__)
  wh = Webhook(os.environ["SOUNDPIECE_WEBHOOK_SECRET"])

  @app.post("/webhooks/soundpiece")
  def handle():
      try:
          payload = wh.verify(request.get_data(), request.headers)
      except Exception:
          abort(403)

      event_type = payload["type"]                # e.g. "create.new_song.ready"
      op = payload["data"]                        # the same DTO you'd poll

      if op["status"] == "ready":
          for output in op["outputs"]:
              ...  # download from output["url"]
      elif op["status"] == "failed":
          log.error("soundpiece op failed: %s", op["error"])

      return "", 200
  ```

  ```javascript Node.js theme={null}
  // npm install standardwebhooks
  import { Webhook } from "standardwebhooks";
  import express from "express";

  const app = express();
  app.use(express.raw({ type: "application/json" }));
  const wh = new Webhook(process.env.SOUNDPIECE_WEBHOOK_SECRET);

  app.post("/webhooks/soundpiece", (req, res) => {
    let payload;
    try {
      payload = wh.verify(req.body, req.headers);
    } catch {
      return res.status(403).end();
    }

    const { type, data: op } = payload;
    if (op.status === "ready") {
      for (const output of op.outputs) {
        // download from output.url
      }
    } else if (op.status === "failed") {
      console.error("soundpiece op failed:", op.error);
    }

    res.status(200).end();
  });
  ```
</CodeGroup>

Libraries for Go, Ruby, Kotlin, Rust, and PHP are available from the same repository.

### Manual verification

If you'd rather not pull in a library, here's the equivalent in plain Python:

```python Python theme={null}
import hmac
import hashlib
import time
import base64
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["SOUNDPIECE_WEBHOOK_SECRET"]  # whsec_<...>
# Strip the whsec_ prefix and base64-decode the key material
key = base64.b64decode(SECRET.removeprefix("whsec_") + "==")

@app.post("/webhooks/soundpiece")
def handle():
    webhook_id = request.headers["webhook-id"]
    webhook_ts = request.headers["webhook-timestamp"]
    webhook_sig = request.headers["webhook-signature"]
    body = request.get_data()

    # 5-minute timestamp window
    if abs(time.time() - int(webhook_ts)) > 300:
        abort(403)

    signed = f"{webhook_id}.{webhook_ts}.{body.decode()}".encode()
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # Compare against every signature in the (space-separated) header
    sigs = [s.split(",", 1)[1] for s in webhook_sig.split() if s.startswith("v1,")]
    if not any(hmac.compare_digest(expected, sig) for sig in sigs):
        abort(403)

    payload = request.get_json()
    ...
```

<Warning>
  Always use a constant-time comparison (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node, equivalent in your language). Direct `==` comparison leaks information about the secret via timing.
</Warning>

***

## Retry behaviour

If your endpoint doesn't return a `2xx`, we retry with exponential backoff up to **6 attempts** (total window \~9 hours):

| Attempt | Delay from previous |
| ------- | ------------------- |
| 1st     | 5 seconds           |
| 2nd     | 30 seconds          |
| 3rd     | 5 minutes           |
| 4th     | 30 minutes          |
| 5th     | 2 hours             |
| 6th     | 6 hours             |

| Response                               | Action                                                                            |
| -------------------------------------- | --------------------------------------------------------------------------------- |
| `2xx`                                  | Delivered. We won't call again for this operation.                                |
| `4xx` (except `429`)                   | Fail immediately — this indicates a configuration issue we can't fix by retrying. |
| `5xx`, `429`, network error or timeout | Retry.                                                                            |

After the final retry attempt fails we give up and log the failure on our side. The operation result remains available via the `GET` endpoint indefinitely — **fall back to polling** if you suspect a delivery was missed.

***

## Secret rotation

Signing secrets are an account-level resource, independent of your API keys — rotating one doesn't touch the other.

To rotate without dropping deliveries:

1. Create a new signing secret in the dashboard. The plaintext is shown once.
2. Roll out the new secret to your webhook handler so it can verify against either old or new.
3. During the cutover, every outgoing webhook is signed with **all** of your non-revoked signing secrets (space-separated, e.g. `v1,<new> v1,<old>`). The Standard Webhooks libraries handle multi-signature verification automatically.
4. Revoke the old secret when you're confident every consumer has migrated.

***

## Local testing

Use a tunnelling tool like [ngrok](https://ngrok.com) to expose your local server during development:

```bash theme={null}
ngrok http 3000
# → Forwarding https://abc123.ngrok.io → http://localhost:3000
```

Then pass the ngrok URL as `callback_url` when submitting test operations.
