Receive search results via signed webhook delivery.
When a search completes (or fails), Soundstripe sends a POST request to the callback_url you provided when creating the search. The webhook payload contains the full search results in the same JSON:API format as the create a search endpoint.
Webhook headers
| Header | Description |
|---|---|
Content-Type | application/vnd.api+json |
X-Soundstripe-Signature | HMAC-SHA256 signature for verifying the webhook authenticity. |
X-Soundstripe-Timestamp | Unix timestamp (seconds) of when the webhook was signed. |
Webhook payload
The payload follows the same format as a completed search response: a JSON:API resource with status: "completed", the original query, context, callback_url, and asset_ids, and included songs with their audio_files and artists resources.
Each included song has the same attributes as the create a search response, including isrc and released_at.
{
"data": {
"id": "fa9b5222768b...",
"type": "supe_searches",
"attributes": {
"status": "completed",
"query": "upbeat indie rock for a travel vlog",
"context": "Summer road trip montage with golden-hour visuals",
"callback_url": "https://your-app.com/webhooks/supe",
"asset_ids": []
},
"relationships": {
"songs": {
"data": [
{ "id": "12556", "type": "songs" }
]
}
},
"links": {
"self": "https://api.soundstripe.com/v1/supe/search/fa9b5222768b..."
}
},
"included": [
{
"id": "12556",
"type": "songs",
"attributes": {
"title": "After The Storm",
"description": "A low energy, classical song that is best described as sad and reflective.",
"energy": "low",
"explicit": false,
"lyrics": "",
"isrc": "USABC1234567",
"released_at": 1751328000,
"bpm": 90,
"key": { "name": "F", "mode": "minor" },
"tags": {
"genre": ["Classical", "Underscore", "Soundtrack / Cinematic"],
"mood": ["Calm", "Reflective", "Sad"],
"instrument": ["Piano", "Strings"],
"characteristic": ["Atmospheric", "Beautiful", "Minimal"]
},
"playlist_ids": [4012, 4097],
"stems": "https://cdn.soundstripe.com/.../Moments_AfterTheStorm.zip",
"has_vocal_version": false,
"has_instrumental_version": true
},
"relationships": {
"artists": {
"data": [{ "id": "506", "type": "artists" }],
"links": {
"self": "https://api.soundstripe.com/v1/songs/12556/relationships/artists",
"related": "https://api.soundstripe.com/v1/artists"
}
},
"audio_files": {
"data": [{ "id": "47652", "type": "audio_files" }],
"links": {
"self": "https://api.soundstripe.com/v1/songs/12556/relationships/audio_files",
"related": "https://api.soundstripe.com/v1/audio_files"
}
}
},
"links": {
"self": "https://api.soundstripe.com/v1/songs/12556"
}
},
{
"id": "47652",
"type": "audio_files",
"attributes": {
"description": "Primary",
"song_id": "12556",
"instrumental": true,
"duration": 226.08,
"versions": {
"mp3": "https://cdn.soundstripe.com/.../mp3_Moments_AfterTheStorm_FULL.mp3",
"wav": "https://cdn.soundstripe.com/.../wav_Moments_AfterTheStorm_FULL.wav"
}
},
"links": {
"self": "https://api.soundstripe.com/v1/audio_files/47652"
}
},
{
"id": "506",
"type": "artists",
"attributes": {
"name": "Moments",
"image": "https://soundstripe-artist-production.imgix.net/.../artist-506.jpg?h=100&w=100"
},
"links": {
"self": "https://api.soundstripe.com/v1/artists/506"
}
}
]
}Verifying the webhook signature
Every webhook includes an X-Soundstripe-Signature header containing an HMAC-SHA256 signature. Verify the signature to confirm the webhook came from Soundstripe and has not been tampered with.
The signature is computed over the string {timestamp}.{payload}, where timestamp is the value of the X-Soundstripe-Timestamp header and payload is the raw request body. Your API key is used as the HMAC secret.
Replay protection. Because the timestamp is part of the signed content, it cannot be changed without invalidating the signature. Reject any delivery whose X-Soundstripe-Timestamp is more than 5 minutes away from your server's current time, so a captured request cannot be replayed later. Each delivery attempt, including each retry, is signed with a fresh timestamp just before it is sent, so legitimate retries fall inside the window. Keep your server clock synchronized (NTP). Use the raw request body exactly as received: parsing and re-serializing the JSON changes the signature. The window does not replace deduplication: a captured request replayed inside the window still verifies, and every legitimate retry carries the same data.id, so deduplicate on it (see Idempotency below).
Ruby:
tolerance_seconds = 300
timestamp = request.headers["X-Soundstripe-Timestamp"].to_s
signature = request.headers["X-Soundstripe-Signature"].to_s
fresh = timestamp.match?(/\A\d+\z/) && (Time.now.to_i - timestamp.to_i).abs <= tolerance_seconds
expected = OpenSSL::HMAC.hexdigest("SHA256", api_key, "#{timestamp}.#{request.raw_post}")
verified = fresh && ActiveSupport::SecurityUtils.secure_compare(expected, signature)Python:
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
timestamp = request.headers.get("X-Soundstripe-Timestamp", "")
signature = request.headers.get("X-Soundstripe-Signature", "")
raw_body = request.body # raw bytes; in Flask use request.get_data()
fresh = timestamp.isdecimal() and abs(time.time() - int(timestamp)) <= TOLERANCE_SECONDS
signed_content = timestamp.encode() + b"." + raw_body
expected = hmac.new(api_key.encode(), signed_content, hashlib.sha256).hexdigest()
verified = fresh and hmac.compare_digest(expected.encode(), signature.encode())Node.js (Express):
const crypto = require("crypto");
const TOLERANCE_SECONDS = 300;
// req.body must be the raw Buffer, not parsed JSON. Mount the route with
// express.raw({ type: "application/vnd.api+json", limit: "10mb" }).
const timestamp = req.get("X-Soundstripe-Timestamp") ?? "";
const signature = Buffer.from(req.get("X-Soundstripe-Signature") ?? "");
const fresh =
/^\d+$/.test(timestamp) &&
Math.abs(Date.now() / 1000 - Number(timestamp)) <= TOLERANCE_SECONDS;
const expected = Buffer.from(
crypto
.createHmac("sha256", apiKey)
.update(`${timestamp}.`)
.update(req.body)
.digest("hex")
);
const verified =
fresh &&
expected.length === signature.length &&
crypto.timingSafeEqual(expected, signature);
Use constant-time comparisonAlways use a constant-time comparison function (like
timingSafeEqual,secure_compare, orcompare_digest) to prevent timing attacks.
Failed searches
If a search fails, a webhook is still delivered with status: "failed" and an empty songs array. Your webhook handler should check the status field before processing results.
{
"data": {
"id": "fa9b5222768b...",
"type": "supe_searches",
"attributes": {
"status": "failed",
"query": "upbeat indie rock for a travel vlog",
"context": "",
"callback_url": "https://your-app.com/webhooks/supe",
"asset_ids": []
},
"relationships": {
"songs": { "data": [] }
},
"links": {
"self": "https://api.soundstripe.com/v1/supe/search/fa9b5222768b..."
}
}
}The webhook payload deliberately omits a top-level links.self. The webhook is a server-initiated POST and has no inbound request URL to reflect; use data.links.self to refetch the search via the retrieve a search endpoint.
A failed search typically reflects a vague or contradictory request that the agent could not converge on, a transient upstream timeout, or an asset that did not finish processing in time. Customers can safely retry a failed search by submitting a new request.
Webhook payload size
Webhook bodies are JSON only, with no binary or base64-encoded audio. Size scales with the number of songs returned and their associated audio_files and artists rows. Observed size distribution from production load testing:
| Percentile | Size |
|---|---|
| p50 | 103 KB |
| Mean | 162 KB |
| p95 | 464 KB |
| p99 | 725 KB |
| Max observed | 1,049 KB |
Receiver sizing recommendation. Configure your webhook receiver to accept request bodies of at least 2 MB, ideally 10 MB, so high-result searches are not rejected by your reverse proxy or framework. Common defaults that need attention:
- Nginx:
client_max_body_size 10m; - Cloudflare Workers or similar edge platforms: confirm your platform's request-body limit.
- AWS API Gateway: default payload limit is 10 MB.
- Node Express body-parsers:
express.json({ limit: "10mb" }).
If your receiver rejects an oversize body with a 4xx such as HTTP 413 Payload Too Large, the delivery is not retried, because a retry would send the same body. The search is marked delivery_failed immediately (see the retry policy below).
Retry policy
Whether a failed delivery is retried depends on your endpoint's response:
- 408, 429, 5xx, timeouts, and connection failures are treated as temporary. The delivery is retried up to 3 times with exponential backoff.
- Any other 4xx (for example 400, 401, 403, 404, 413, or 422) means your endpoint rejected this delivery, so it is not retried.
When a delivery is not retried, or all retries are exhausted, the search is marked delivery_failed. The webhook is not sent again, but the results can still be retrieved using the retrieve a search endpoint. Your webhook handler should return a 2xx status code to acknowledge receipt.
Idempotency
Webhooks may be delivered more than once due to retries or race conditions. Use the data.id field in the payload to deduplicate. Your webhook handler should be idempotent.