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

# Webhooks

> Receive real-time job completion events. Setup, signature verification, retry logic, and reading logs.

# Webhooks

Zupertry sends a signed HTTP POST to your webhook URL when a job completes or fails. Webhooks are the recommended way to receive results — more reliable than polling.

## Setup

Provide your webhook URL when submitting a job:

```json theme={null}
{
  "model_image_url": "https://...",
  "garment_image_url": "https://...",
  "webhook_url": "https://your-app.com/webhooks/zupertry"
}
```

Or set a default webhook URL for your entire organisation in the console under **Settings → Webhook URL**. The per-request `webhook_url` overrides the org-level default.

## Event types

| Event           | When sent                                        |
| --------------- | ------------------------------------------------ |
| `job.completed` | Job finished successfully, output image is ready |
| `job.failed`    | Job failed after all retries, credit refunded    |

## Payload shape

```json theme={null}
{
  "type": "job.completed",
  "data": {
    "job_id": "job_abc123",
    "workspace_id": "ws_xyz",
    "org_id": "org_def456",
    "status": "completed",
    "output_url": "https://storage.googleapis.com/zupertry-outputs/...",
    "credits_consumed": 1,
    "created_at": "2026-03-19T10:00:00.000Z",
    "completed_at": "2026-03-19T10:00:05.231Z"
  }
}
```

For `job.failed`:

```json theme={null}
{
  "type": "job.failed",
  "data": {
    "job_id": "job_abc123",
    "status": "failed",
    "error": "Vertex AI returned an unexpected response format",
    "credits_consumed": 0,
    "created_at": "2026-03-19T10:00:00.000Z"
  }
}
```

## Signature verification

Every webhook request includes an `X-Zupertry-Signature` header. This is an HMAC-SHA256 of the raw request body, signed with your webhook signing secret.

<Warning>
  Always verify the signature before processing a webhook. Skip this and anyone can forge events to your endpoint.
</Warning>

### Node.js

```javascript theme={null}
const crypto = require('crypto');

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody) // must be the raw Buffer, not parsed JSON
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

// Express example
app.use('/webhooks/zupertry', express.raw({ type: 'application/json' }));
app.post('/webhooks/zupertry', (req, res) => {
  const valid = verifySignature(
    req.body,
    req.headers['x-zupertry-signature'],
    process.env.ZUPERTRY_WEBHOOK_SECRET
  );

  if (!valid) return res.status(401).json({ error: 'Invalid signature' });

  const event = JSON.parse(req.body.toString());
  // handle event...
  res.json({ received: true });
});
```

### Python (Flask)

```python theme={null}
import hmac, hashlib, os
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks/zupertry', methods=['POST'])
def webhook():
    sig = request.headers.get('X-Zupertry-Signature', '')
    secret = os.environ['ZUPERTRY_WEBHOOK_SECRET'].encode()
    expected = hmac.new(secret, request.data, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(sig, expected):
        return jsonify({'error': 'Invalid signature'}), 401

    event = request.get_json(force=True)
    print(f"Event: {event['type']}", event['data']['job_id'])
    return jsonify({'received': True})
```

## Retry logic

If your endpoint returns a non-2xx status (or doesn't respond within 10 seconds), Zupertry retries the delivery:

| Attempt          | Delay                            |
| ---------------- | -------------------------------- |
| 1st retry        | 60 seconds                       |
| 2nd retry        | 5 minutes                        |
| After 3 failures | Marked `failed`, visible in Logs |

<Tip>
  Always respond `200 OK` as quickly as possible (within the 10-second window). Do any heavy processing asynchronously — add the event to a queue and process it later.
</Tip>

## Idempotency

Your endpoint may receive the same event more than once (network timeouts cause retries even after successful delivery). Always make your handler idempotent by using `job_id` as a deduplication key:

```javascript theme={null}
const processedJobs = new Set(); // use Redis or database in production

app.post('/webhooks/zupertry', (req, res) => {
  // ... signature verification ...

  const event = JSON.parse(req.body.toString());
  const jobId = event.data.job_id;

  if (processedJobs.has(jobId)) {
    return res.json({ received: true, duplicate: true }); // already handled
  }

  processedJobs.add(jobId);
  // process the event...
  res.json({ received: true });
});
```

## Reading webhook logs

All delivery attempts are logged in the Zupertry console under **Logs → Webhook logs**.

Each row shows:

* Delivery attempt number
* HTTP status returned by your endpoint
* Response time (ms)
* Timestamp

Click any row to expand the full request payload and response body.

## Testing webhooks locally

Use [ngrok](https://ngrok.com) to expose your local server:

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

Use the ngrok URL as your webhook URL when submitting test jobs.
