Skip to main content

Set Up Callback Notifications

Get notified instantly when jobs complete instead of polling for status.

What You'll Need

  • Callback Secret - A 32-byte HMAC-SHA256 signing key, provided to you as a 64-character hex-encoded string (e.g. 4f8a9b2c1d3e5f7081a2b3c4d5e6f7081928374655a6b7c8d9e0f1a2b3c4d5e6). Decode the hex to raw bytes before using it as the HMAC key — see Security.
  • Publicly accessible HTTPS endpoint - To receive callback notifications
  • Access Token - See Get an Access Token guide
Local Development

For local testing, you can use ngrok to create a public URL for your local server. See the Local Development section below.

Why Callbacks?

  • ✅ Instant notifications when jobs complete
  • ✅ No wasted API calls
  • ✅ Scales with any number of concurrent jobs

When you submit a job with a callback_url, SASHA will send an HTTP POST request to your endpoint when the job status changes:

Create a Callback Endpoint

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) => {
// Check for duplicates using request ID
if (await isDuplicate(requestId)) 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);

Submit Jobs with Callback URL

Include your callback_url when submitting jobs. Optionally pin the Callback Secret ID SASHA should use to sign the callback — see Callback Secret ID for when that matters.

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.

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 (response header on the callback) — always present on the inbound callback request. Tells you which Callback Secret SASHA actually used to compute the SASHA-Request-Signature, so your endpoint can look up the matching signing key.

If you only have a single Callback Secret configured, you can ignore both — there's no ambiguity to resolve.

Verifying with multiple Callback Secrets

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' });
}
Decode the hex secret before signing

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:

  1. Take the HTTP request method (e.g., POST)
  2. Concatenate the request URL (e.g., https://your-app.com/callbacks/sasha-job-update)
  3. Concatenate the value of the SASHA-Request-ID header
  4. Concatenate the raw request payload (body as a string)
  5. 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.

Example Signature Validation

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
danger

While signature validation is not mandatory, SASHA strongly recommends it to ensure the payload genuinely comes from SASHA and was not modified in transit.

Best Practices

1. Respond Immediately

Return 200 OK quickly, then process asynchronously:

// Good
res.status(200).send('OK');
processCallback(job).catch(console.error);

// Bad - blocks the response
await processCallback(job);
res.status(200).send('OK');

2. Handle Duplicates

SASHA may retry delivery. SASHA-Request-ID is unique per delivery attempt, so use the job_id from the payload to recognize duplicate callbacks for the same job across redeliveries:

const requestId = req.headers['sasha-request-id'];
const jobId = req.body.job_id;
if (await isDuplicate(jobId)) {
return res.status(200).send('OK');
}

3. Download Images Promptly

The output_url expires. Download immediately:

if (job.status === 'completed' && job.output_url) {
await fetch(job.output_url)
.then(r => r.arrayBuffer())
.then(data => saveToStorage(job.job_id, data));
}

4. Handle Failures

Different error codes need different handling:

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

Use ngrok to test callbacks locally:

# 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

IssueSolution
Callbacks not receivedEnsure endpoint is publicly accessible via HTTPS
Invalid signatureCheck HMAC-SHA256 calculation and Callback Secret. Make sure you decoded the hex-encoded secret into raw bytes before using it as the HMAC key.
Wrong signatureEnsure URL doesn't include query params or fragments in signature calculation
Duplicate processingImplement idempotency using SASHA-Request-ID
TimeoutsRespond with 200 OK immediately, process async

See Also