Set Up Callback Notifications
Receive the finished job at your own endpoint instead of polling for it.
Before you start
- Callback Secret — a 32-byte HMAC-SHA256 signing key, delivered as a 64-character hex string (for example
4f8a9b2c1d3e5f7081a2b3c4d5e6f7081928374655a6b7c8d9e0f1a2b3c4d5e6). Decode the hex to raw bytes before you use it as the HMAC key. See Security. - A public HTTPS endpoint — SASHA posts the job to this URL.
- An access token — see Get an access token.
To test against a server on your own machine, give it a public URL with ngrok. See Local development.
Why callbacks
A callback removes the polling loop. SASHA posts the finished job to your endpoint, so your service makes no status requests. One endpoint serves any number of concurrent jobs.
Submit a job with a callback_url, and SASHA sends an HTTP POST to that URL when the job status changes:
Create a callback endpoint
- Node.js (Express)
- Python (Flask)
- PHP
- Go (net/http)
- Rust (axum)
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// SASHA_CALLBACK_SECRET is the hex-encoded Callback Secret provided by SASHA.
// Decode it once to the raw 32-byte buffer used as the HMAC key.
const callbackSecret = Buffer.from(process.env.SASHA_CALLBACK_SECRET, 'hex');
app.post('/callbacks/sasha-job-update', async (req, res) => {
// Validate HMAC-SHA256 signature
const requestSignature = req.headers['sasha-request-signature'];
const requestId = req.headers['sasha-request-id'];
const requestMethod = req.method;
const requestUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const requestPayload = JSON.stringify(req.body);
const computedSignature = crypto
.createHmac('sha256', callbackSecret)
.update(`${requestMethod}${requestUrl}${requestId}${requestPayload}`)
.digest('hex');
if (computedSignature !== requestSignature) {
return res.status(401).json({ error: 'Invalid signature' });
}
const job = req.body;
// Respond immediately
res.status(200).send('OK');
// Process asynchronously
processCallback(job, requestId).catch(console.error);
});
const processCallback = async (job, requestId) => {
// Recognize a repeat by job ID. SASHA-Request-ID is new on every
// delivery attempt, so it cannot identify one.
if (await isDuplicate(job.job_id)) return;
if (job.status === 'completed') {
console.log('Job completed:', job.job_id);
// Download protected image before it expires
if (job.output_url) {
await downloadImage(job.output_url, job.job_id);
}
// Store signature ID and notify user
await storeResult(job.job_id, job.signature_id);
}
else if (job.status === 'failed') {
console.error('Job failed:', job.error);
await handleFailure(job);
}
};
app.listen(3000);
import hashlib
import hmac
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
# SASHA_CALLBACK_SECRET is the hex-encoded Callback Secret provided by SASHA.
# Decode it once to the raw 32-byte buffer used as the HMAC key.
callback_secret = bytes.fromhex(os.environ["SASHA_CALLBACK_SECRET"])
@app.post("/callbacks/sasha-job-update")
def sasha_callback():
# Validate HMAC-SHA256 signature
request_signature = request.headers.get("SASHA-Request-Signature", "")
request_id = request.headers.get("SASHA-Request-ID", "")
request_method = request.method
request_url = request.base_url # scheme + host + path, no query/fragment
request_payload = request.get_data(as_text=True)
string_to_sign = f"{request_method}{request_url}{request_id}{request_payload}"
computed_signature = hmac.new(
callback_secret,
string_to_sign.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# Constant-time compare to resist timing attacks
if not hmac.compare_digest(computed_signature, request_signature):
return jsonify(error="Invalid signature"), 401
job = request.get_json()
# Respond immediately, process asynchronously (e.g. enqueue to Celery)
process_callback.delay(job, request_id)
return "OK", 200
@celery.task
def process_callback(job, request_id):
# Recognize a repeat by job ID. SASHA-Request-ID is new on every
# delivery attempt, so it cannot identify one.
if is_duplicate(job["job_id"]):
return
if job["status"] == "completed":
# Download protected image before it expires
if job.get("output_url"):
download_image(job["output_url"], job["job_id"])
store_result(job["job_id"], job.get("signature_id"))
elif job["status"] == "failed":
handle_failure(job)
<?php
// public/callbacks/sasha-job-update.php
// SASHA_CALLBACK_SECRET is the hex-encoded Callback Secret provided by SASHA.
// Decode it to the raw 32-byte string used as the HMAC key.
$callbackSecret = hex2bin(getenv('SASHA_CALLBACK_SECRET'));
// Validate HMAC-SHA256 signature
$requestSignature = $_SERVER['HTTP_SASHA_REQUEST_SIGNATURE'] ?? '';
$requestId = $_SERVER['HTTP_SASHA_REQUEST_ID'] ?? '';
$requestMethod = $_SERVER['REQUEST_METHOD'];
$scheme = (!empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
? 'https'
: 'http';
$requestUrl = $scheme . '://' . $_SERVER['HTTP_HOST']
. parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$requestPayload = file_get_contents('php://input');
$computedSignature = hash_hmac(
'sha256',
$requestMethod . $requestUrl . $requestId . $requestPayload,
$callbackSecret
);
// Constant-time compare to resist timing attacks
if (!hash_equals($computedSignature, $requestSignature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$job = json_decode($requestPayload, true);
// Respond immediately, then process asynchronously.
// fastcgi_finish_request() flushes the response so the client doesn't wait.
http_response_code(200);
echo 'OK';
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
processCallback($job, $requestId);
function processCallback(array $job, string $requestId): void
{
// Recognize a repeat by job ID. SASHA-Request-ID is new on every
// delivery attempt, so it cannot identify one.
if (isDuplicate($job['job_id'])) {
return;
}
if ($job['status'] === 'completed') {
if (!empty($job['output_url'])) {
downloadImage($job['output_url'], $job['job_id']);
}
storeResult($job['job_id'], $job['signature_id'] ?? null);
} elseif ($job['status'] === 'failed') {
handleFailure($job);
}
}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)
// callbackSecret holds the raw 32-byte HMAC key, decoded from the
// hex-encoded Callback Secret provided by SASHA.
var callbackSecret []byte
func sashaCallback(w http.ResponseWriter, r *http.Request) {
// Validate HMAC-SHA256 signature
requestSignature := r.Header.Get("SASHA-Request-Signature")
requestID := r.Header.Get("SASHA-Request-ID")
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
requestURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
requestPayload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body failed", http.StatusBadRequest)
return
}
defer r.Body.Close()
mac := hmac.New(sha256.New, callbackSecret)
fmt.Fprintf(mac, "%s%s%s%s", r.Method, requestURL, requestID, requestPayload)
computedSignature := hex.EncodeToString(mac.Sum(nil))
// Constant-time compare to resist timing attacks
if !hmac.Equal([]byte(computedSignature), []byte(requestSignature)) {
http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
return
}
var job map[string]any
if err := json.Unmarshal(requestPayload, &job); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// Respond immediately; process asynchronously.
w.WriteHeader(http.StatusOK)
io.WriteString(w, "OK")
go processCallback(job, requestID)
}
func processCallback(job map[string]any, requestID string) {
// Recognize a repeat by job ID. SASHA-Request-ID is new on every
// delivery attempt, so it cannot identify one.
if isDuplicate(job["job_id"].(string)) {
return
}
switch job["status"] {
case "completed":
if u, ok := job["output_url"].(string); ok && u != "" {
downloadImage(u, job["job_id"].(string))
}
storeResult(job["job_id"].(string), job["signature_id"])
case "failed":
handleFailure(job)
}
}
func main() {
secret, err := hex.DecodeString(os.Getenv("SASHA_CALLBACK_SECRET"))
if err != nil {
log.Fatalf("invalid SASHA_CALLBACK_SECRET hex: %v", err)
}
callbackSecret = secret
http.HandleFunc("/callbacks/sasha-job-update", sashaCallback)
log.Fatal(http.ListenAndServe(":3000", nil))
}
// Cargo.toml dependencies:
// axum = "0.7"
// tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
// hmac = "0.12"
// sha2 = "0.10"
// hex = "0.4"
// subtle = "2"
// serde_json = "1"
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode, Uri},
response::IntoResponse,
routing::post,
Router,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::{net::SocketAddr, sync::Arc};
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
struct AppState {
/// Raw 32-byte HMAC key, decoded from the hex-encoded Callback Secret.
callback_secret: Arc<Vec<u8>>,
}
async fn sasha_callback(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
body: Bytes,
) -> impl IntoResponse {
// Validate HMAC-SHA256 signature
let request_signature = header_str(&headers, "SASHA-Request-Signature");
let request_id = header_str(&headers, "SASHA-Request-ID");
let host = header_str(&headers, "host");
let request_url = format!("https://{host}{path}", path = uri.path());
let mut mac = HmacSha256::new_from_slice(&state.callback_secret)
.expect("HMAC accepts any key length");
mac.update(b"POST");
mac.update(request_url.as_bytes());
mac.update(request_id.as_bytes());
mac.update(&body);
let computed_signature = hex::encode(mac.finalize().into_bytes());
// Constant-time compare to resist timing attacks
if computed_signature.as_bytes().ct_eq(request_signature.as_bytes()).unwrap_u8() == 0 {
return (StatusCode::UNAUTHORIZED, r#"{"error":"Invalid signature"}"#).into_response();
}
let Ok(job) = serde_json::from_slice::<serde_json::Value>(&body) else {
return (StatusCode::BAD_REQUEST, "bad json").into_response();
};
// Respond immediately; process asynchronously.
let request_id = request_id.to_string();
tokio::spawn(async move { process_callback(job, request_id).await });
(StatusCode::OK, "OK").into_response()
}
fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> &'a str {
headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or("")
}
#[tokio::main]
async fn main() {
// SASHA_CALLBACK_SECRET is the hex-encoded Callback Secret provided by SASHA.
let secret_hex = std::env::var("SASHA_CALLBACK_SECRET").expect("SASHA_CALLBACK_SECRET");
let callback_secret = Arc::new(hex::decode(secret_hex).expect("invalid hex"));
let app = Router::new()
.route("/callbacks/sasha-job-update", post(sasha_callback))
.with_state(AppState { callback_secret });
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], 3000)))
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Submit a job with a callback URL
Send callback_url with the job. To pin the callback to one signing key,
also send callback_secret_id — see Callback Secret ID
for when that matters.
- REST
- gRPC
curl https://partner.api.sasha.eu/signature/embed \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"media_url": "https://example.com/image.jpg",
"media_mime_type": "image/jpeg",
"callback_url": "https://your-app.com/callbacks/sasha-job-update",
"callback_secret_id": "177F01DA-34F2-4318-9763-B73876FDD7FA"
}'
callback_secret_id is optional; omit it to let SASHA pick one of your
enabled Callback Secrets automatically.
const embedResponse = await signatureClient.embedSignatureFromURL(
{
mediaUrl: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
callbackUrl: "https://your-app.com/callbacks/sasha-job-update",
callbackSecretId: "177F01DA-34F2-4318-9763-B73876FDD7FA",
},
{ metadata }
);
callbackSecretId is optional; omit it to let SASHA pick one of your
enabled Callback Secrets automatically.
Callback Secret ID
Each Callback Secret you have configured with SASHA has a unique Callback Secret ID (a UUID). It identifies which signing key was used and lets you operate more than one Callback Secret at a time — typically for safe key rotation, or when different parts of your system verify callbacks with different keys.
Two surfaces use it:
callback_secret_id(request field) — pass it when submitting an embed or lookup job to pin the callback for that job to a specific Callback Secret. If omitted, SASHA picks one of your enabled Callback Secrets automatically.SASHA-Callback-Secret-ID(header on the callback) — sent on every callback. It names the Callback Secret SASHA used to computeSASHA-Request-Signature, so your endpoint can select the matching signing key. The value is empty if your account still signs with a legacy shared secret rather than an issued Callback Secret.
With a single Callback Secret configured, ignore both. There is no ambiguity to resolve.
Verify with more than one Callback Secret
Keep a map from Callback Secret ID to its raw HMAC key, and look up the right one per request:
// Map of Callback Secret ID -> raw 32-byte HMAC key
const callbackSecrets = {
'177F01DA-34F2-4318-9763-B73876FDD7FA': Buffer.from(process.env.SASHA_CALLBACK_SECRET_PRIMARY, 'hex'),
'8A4E1B7C-9D2F-4A56-B3E8-1C9F0D5E2A7B': Buffer.from(process.env.SASHA_CALLBACK_SECRET_ROTATED, 'hex'),
};
app.post('/callbacks/sasha-job-update', (req, res) => {
const callbackSecretId = req.headers['sasha-callback-secret-id'];
const callbackSecret = callbackSecrets[callbackSecretId];
if (!callbackSecret) {
// Unknown Callback Secret ID — refuse to validate.
return res.status(401).json({ error: 'Unknown Callback Secret ID' });
}
// Validate SASHA-Request-Signature using callbackSecret as the HMAC key.
// ...
});
This is what makes zero-downtime rotation possible: while both the old
and new Callback Secrets are enabled, SASHA may use either, and your
endpoint can verify either by selecting the right key from the map via
SASHA-Callback-Secret-ID.
Security
SASHA callbacks include an HMAC-SHA256 Callback Payload Signature so you can verify both that the request came from SASHA and that it was not modified in transit:
// SASHA_CALLBACK_SECRET is the hex-encoded Callback Secret provided by SASHA.
// Decode it to a Buffer of raw bytes — that buffer is the actual HMAC key.
const callbackSecret = Buffer.from(process.env.SASHA_CALLBACK_SECRET, 'hex');
const requestSignature = req.headers['sasha-request-signature'];
const requestId = req.headers['sasha-request-id'];
const requestMethod = req.method;
const requestUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const requestPayload = JSON.stringify(req.body);
const computedSignature = crypto
.createHmac('sha256', callbackSecret)
.update(`${requestMethod}${requestUrl}${requestId}${requestPayload}`)
.digest('hex');
if (computedSignature !== requestSignature) {
return res.status(401).json({ error: 'Invalid signature' });
}
The Callback Secret is delivered as a hex-encoded string for safe copy-paste. The HMAC key is the 32 raw bytes that hex string represents, not the hex characters themselves. Always decode hex → bytes (e.g. Buffer.from(secretHex, 'hex') in Node.js, bytes.fromhex(secret_hex) in Python) before passing it to your HMAC implementation, or your computed signatures will not match.
The signature is calculated as follows:
- Take the HTTP request method (e.g.,
POST) - Concatenate the request URL (e.g.,
https://your-app.com/callbacks/sasha-job-update) - Concatenate the value of the
SASHA-Request-IDheader - Concatenate the raw request payload (body as a string)
- Compute the HMAC-SHA256 digest of the resulting string using your Callback Secret (decoded from hex to raw bytes) as the key
signature = HMAC_SHA256(
request_method + request_url + request_id + request_payload,
callback_secret
)
- There are no separators or padding between the concatenated values.
A worked example
Given:
- Callback Secret (hex-encoded, as provided by SASHA):
4f8a9b2c1d3e5f7081a2b3c4d5e6f7081928374655a6b7c8d9e0f1a2b3c4d5e6 - HMAC key (raw bytes after hex decoding):
Buffer.from('4f8a9b2c1d3e5f7081a2b3c4d5e6f7081928374655a6b7c8d9e0f1a2b3c4d5e6', 'hex') - Request method:
POST - Request URL:
https://your-app.com/callbacks/sasha-job-update - Request ID:
aa-b-c-d-ee - Request payload:
{"job_id":"44cab986-0385-470a-8e5c-c657b0543d19","type":"embed-signature","status":"completed","output_url":"https://storage.sasha.eu/1420f12bb0e7a31104f90311d6e","output_url_expires_at":"2026-01-01T13:00:00.000Z","signature_id":"340558932877813735","creator_id":1,"created_at":"2026-01-01T12:00:00.000Z","updated_at":"2026-01-01T12:00:02.000Z"}
Concatenated string:
POSThttps://your-app.com/callbacks/sasha-job-updateaa-b-c-d-ee{"job_id":"44cab986-0385-470a-8e5c-c657b0543d19","type":"embed-signature","status":"completed","output_url":"https://storage.sasha.eu/1420f12bb0e7a31104f90311d6e","output_url_expires_at":"2026-01-01T13:00:00.000Z","signature_id":"340558932877813735","creator_id":1,"created_at":"2026-01-01T12:00:00.000Z","updated_at":"2026-01-01T12:00:02.000Z"}
Expected signature:
8c37da02969bcc8fc9392a1e4ffac332a0c7248df7301a2484f2d40d4822db2d
An endpoint that skips validation accepts a forged job update from anyone who learns its URL. Compare the signature before you read the payload.
Delivery and retries
SASHA attempts each callback up to five times, over about 22 minutes. An attempt fails when your endpoint does not respond within 10 seconds, refuses the connection, or answers with 429 or a 5xx status.
The wait grows after each failure:
| Attempt | Sent after |
|---|---|
| 1 | immediately |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 5 minutes |
| 5 | 15 minutes |
An endpoint that restarts, or a dependency that is briefly unavailable, therefore recovers on its own without you losing the job notification.
Any 4xx other than 429 ends delivery immediately. A 4xx says the request itself is wrong, and repeating it cannot change that.
Your status code controls what SASHA does next:
| Your response | What SASHA does |
|---|---|
2xx | Treats the job as delivered. |
429 or 5xx | Retries, up to the five-attempt limit. |
Any other 4xx | Stops. No further attempt is made. |
| No response within 10 seconds | Retries, up to the five-attempt limit. |
So return 503 when your service is briefly unable to accept the job, and SASHA will try again. Return 400 only when the job will never be accepted.
After the fifth failed attempt the callback is not delivered. The job itself is unaffected: it completed before the first attempt, and Get Job returns it with the same output_url and signature_id. Reconcile against that endpoint if a callback you expected never arrives.
Practices
1. Return 200 OK before you process
SASHA gives your endpoint 10 seconds to respond, then aborts the request and counts the attempt as failed. Respond first, then do the work:
// Good
res.status(200).send('OK');
processCallback(job).catch(console.error);
// Bad - blocks the response
await processCallback(job);
res.status(200).send('OK');
2. Ignore a repeat delivery
Your endpoint can receive the same job twice — a retry after a response that never reached SASHA, for example. SASHA-Request-ID is new on every delivery attempt, so it cannot identify a repeat. Key on job_id instead:
const jobId = req.body.job_id;
if (await isDuplicate(jobId)) {
return res.status(200).send('OK');
}
Retries of the same callback carry an identical body, so output_url does not change between them. Treat the newest delivery as current anyway: a job that SASHA had to reprocess produces a fresh callback with a new output_url, and the old one stops working.
3. Download the image at once
output_url expires. Download the image as soon as the callback arrives:
if (job.status === 'completed' && job.output_url) {
await fetch(job.output_url)
.then(r => r.arrayBuffer())
.then(data => saveToStorage(job.job_id, data));
}
4. Branch on the error code
Each error code needs a different response:
if (job.status === 'failed') {
switch (job.error.code) {
case 'unsupported_format':
// Don't retry - invalid format
await notifyUser('Use JPEG or PNG format');
break;
case 'failed_to_fetch_from_url':
case 'internal':
// May retry or escalate
await handleRetryableError(job);
break;
}
}
Local development
Test callbacks against a server on your own machine with ngrok:
# Terminal 1: Start your server
node server.js
# Terminal 2: Create tunnel
ngrok http 3000
# Use the ngrok URL as your callback_url
# Example: https://abc123.ngrok.io/callbacks/sasha-job-update
Common issues
| Issue | Fix |
|---|---|
| No callback arrives | Your endpoint must answer a public HTTPS request. Call it with curl from outside your own network. Read the job with Get Job to recover the result. |
| Callbacks stop after one attempt | Your endpoint answered with a 4xx other than 429, which ends delivery. Return 503 for a temporary failure instead. |
| The signature never matches | Decode the hex Callback Secret to raw bytes first, and sign the URL path only. Drop the query string and the fragment. |
| The same job runs twice | Key deduplication on job_id, not on SASHA-Request-ID. |
| Your endpoint times out | Return 200 OK within 10 seconds. Process the job after you respond. |
See also
- Quickstart — the polling alternative to callbacks
- Handle Token Expiration — token lifetime and renewal
- Authentication — how SASHA authenticates a callback to your endpoint