# Guide to Argil Webhooks: Features and Best Practices

[Argil](https://www.argil.ai) turns scripts into videos presented by AI avatars, and lets you train a custom avatar from your own footage. Both jobs take time, so Argil's webhooks tell you when a render or a training has finished, or failed, instead of making you poll the videos and avatars endpoints. A typical receiver downloads the finished video, marks a job as done in your own database, or tells a user that their new avatar is ready.

Below you'll find how Argil webhooks are registered through the API, what the four events contain, how to check the Standard Webhooks signature, and where Argil's delivery rules can trip you up.

## What are Argil webhooks?

An Argil webhook is a callback URL plus a list of events, created with a call to the Argil API. There is no settings page for them: the [webhook introduction](https://docs.argil.ai/pages/webhook-events/introduction) describes list, create, update and delete calls only. A single webhook can subscribe to several events, so one endpoint can handle everything Argil sends.

Each delivery is a `POST` with a small JSON body, `{ "event": ..., "data": ... }`, and three headers from the Standard Webhooks specification. The signing secret comes back in the response to the create call and nowhere else. Webhooks created before Argil added signing still arrive, but without any signature headers.

## Argil webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: `GET` and `POST https://api.argil.ai/v1/webhooks`, plus update and delete |
| Authentication for the API | `x-api-key` header |
| Subscription model | One callback URL and a list of events per webhook |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature` (`v1,` + base64) |
| Other headers | `webhook-id` (event ID, unchanged across retries), `webhook-timestamp` (Unix seconds of the attempt) |
| Secret | `whsec_` + base64, returned only by the create call |
| Body | JSON `{event, data}` |
| Event routing | Body field `event` |
| Response window | Any `2xx` within 10 seconds. Redirects are not followed |
| Retries | 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, then every 12 hours, for 3 days |
| Ordering | At least once, can arrive out of order |
| Disabling | Answer `410 Gone`, or fail for 3 days (the workspace owner is emailed) |
| Source IPs | Not published |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `VIDEO_GENERATION_SUCCESS` | An avatar video render completes | Download or publish `videoUrl`, mark the job done |
| `VIDEO_GENERATION_FAILED` | A render fails | Alert, retry, check whether the credit debit was refunded |
| `AVATAR_TRAINING_SUCCESS` | An avatar training completes | Enable the avatar (`avatarId`, `voiceId`) in your app |
| `AVATAR_TRAINING_FAILED` | An avatar training fails | Notify the user, ask for new source footage |

Argil's docs list these four and don't say the list is complete, so return `200` for any event name you don't recognise.

### Video events

A successful render sends the video's ID, name and public URL, plus the credits it cost:

```json
{
  "event": "VIDEO_GENERATION_SUCCESS",
  "data": {
    "videoId": "<video_id>",
    "videoName": "<video_name>",
    "videoUrl": "<public_url_to_access_video>",
    "message": "Video generation completed successfully",
    "extras": "<additional_key-value_data_related_to_the_video>",
    "renderCost": 150,
    "renderCostRefunded": false
  }
}

```

`VIDEO_GENERATION_FAILED` has the same shape without `videoUrl`, and `message` explains why the render failed. `renderCost` is the number of credits debited, or `0` for a free render. Argil refunds a failed render before it sends the webhook, so `renderCostRefunded` is usually `true` on a failure. If the refund itself failed, the flag is `false` and the charge stands, which is worth surfacing to whoever handles billing.

### Avatar events

`AVATAR_TRAINING_SUCCESS` carries `avatarId`, `voiceId`, `avatarName` and `extras`. `AVATAR_TRAINING_FAILED` carries the same fields without `voiceId`. The failure page's prose calls the event `AVATAR_GENERATION_FAILED`, but its example, the subscription enum and the introduction all use `AVATAR_TRAINING_FAILED`, so match on the latter.

The examples in Argil's docs use `<placeholder>` strings rather than real values, and `extras` is described only as additional key-value data, so check its type against a real delivery before you rely on it.

## Setting up Argil webhooks

You need an Argil [API key](https://docs.argil.ai/resources/introduction) and a public HTTPS URL. Create the webhook with the events you want:

```bash
curl -X POST https://api.argil.ai/v1/webhooks \
  -H "x-api-key: $ARGIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"callbackUrl": "https://your-app.com/webhooks/argil",
       "events": ["VIDEO_GENERATION_SUCCESS", "VIDEO_GENERATION_FAILED",
                  "AVATAR_TRAINING_SUCCESS", "AVATAR_TRAINING_FAILED"]}'

```

`callbackUrl` and at least one entry in `events` are required. The `201` response holds the webhook's `id`, `callbackUrl`, `events`, `createdAt` and `updatedAt`, and a `secret` such as `whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD`. Save that secret as `ARGIL_WEBHOOK_SECRET` straight away. Argil documents no call that returns it again, so if it is lost you delete the webhook and create a new one.

| Action | Call |
| --- | --- |
| List | `GET https://api.argil.ai/v1/webhooks` ([reference](https://docs.argil.ai/api-reference/endpoint/webhooks.list)) |
| Create | `POST https://api.argil.ai/v1/webhooks` ([reference](https://docs.argil.ai/api-reference/endpoint/webhooks.create)) |
| Update (URL or events, re-enables a disabled webhook) | [reference](https://docs.argil.ai/api-reference/endpoint/webhooks.update) |
| Delete | [reference](https://docs.argil.ai/api-reference/endpoint/webhooks.delete) |

To try it locally, forward deliveries to your machine with the [Hookdeck CLI](/docs/cli) and register the URL it prints:

```bash
npx hookdeck-cli listen 3000 argil --path /webhooks/argil

```

Then render a short test video and watch for `VIDEO_GENERATION_SUCCESS`.

**With Hookdeck.** Create a source with the **Argil** type, paste the full `whsec_` secret, and use the source URL as `callbackUrl`. Webhooks created before Argil added signing are unsigned. Recreate the webhook to get a whsec_ secret. Requests with any method other than `POST` are refused. When a request fails the signature check, Hookdeck still answers Argil with a `200`, records the request as failed verification and does not forward it.

## Securing Argil webhooks

Argil signs `{webhook-id}.{webhook-timestamp}.{raw body}` with HMAC-SHA256 and sends the result as `v1,<base64>` in `webhook-signature`. The key is the part of the secret after `whsec_`, base64-decoded. The header can hold several space-separated signatures, and a match on any `v1` entry is enough.

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Wire it into an Express route that keeps the raw body. Because Argil retries with the same `webhook-id`, that header makes a natural idempotency key:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/argil', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.ARGIL_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const payload = parseJsonObject(req.body);
  if (!payload) return res.sendStatus(400); // signed, but not a JSON object
  const { event, data } = payload;
  if (typeof event !== 'string' || !data || typeof data !== 'object') return res.sendStatus(400);
  // Store req.headers['webhook-id'] first and skip IDs you've already seen.
  try {
    if (event === 'VIDEO_GENERATION_SUCCESS') {
      await queueVideoDownload(data.videoId, data.videoUrl);
    } else if (event === 'VIDEO_GENERATION_FAILED' && !data.renderCostRefunded) {
      await flagUnrefundedRender(data.videoId, data.renderCost);
    }
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

```

`queueVideoDownload` and `flagUnrefundedRender` stand in for your own code. Keep them quick, or hand the work to a queue, because Argil gives up on an attempt after 10 seconds.

Argil's own example uses the `standardwebhooks` package. That library refuses any `webhook-timestamp` more than 5 minutes from your server's clock. Hookdeck sets no timestamp limit and keeps Argil's original headers when it retries or replays a request, so a delivery that waited in Hookdeck can fail the library's check even though it is genuine. The function above checks the signature only. If you want a freshness check on direct traffic, add one with a window longer than any delay you expect.

> **Make Argil webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks Argil's Standard Webhooks signature, keeps every render and training result, and lets you replay one when your handler had a bad day.

## Argil webhook limitations and pain points

### Legacy webhooks are unsigned

**The Problem:** An older webhook keeps delivering events, but none of them carries a signature.

**Why It Happens:** Argil added signing later. Webhooks created before then are still delivered without signature headers, and Argil's docs describe no way to add signing to an existing webhook.

**Workarounds:**

* List your webhooks, delete any created before signing, and create them again to receive a secret.
* Until you do, treat those deliveries as unverified notifications and fetch the video or avatar from the API before acting.

**How Hookdeck Can Help:** Once the Argil source has a secret, Hookdeck marks unsigned requests as failed verification, so an old webhook pointed at it shows up quickly.

### The secret is shown once

**The Problem:** You've lost the signing secret and can't verify anything.

**Why It Happens:** Only the create response contains `secret`. The list and update calls don't return it, and Argil documents no rotate call.

**Workarounds:**

* Write the secret to your secret manager in the same script that creates the webhook.
* If it's gone, delete the webhook, create a new one and update your configuration.

**How Hookdeck Can Help:** With Hookdeck in front, the secret lives on the source, so replacing it is one change rather than a redeploy of every receiver.

### Ten seconds and no redirects

**The Problem:** Deliveries keep being retried even though your handler eventually succeeds.

**Why It Happens:** Argil counts anything slower than 10 seconds as a failure and doesn't follow redirects, so a `301` from a moved route or an HTTP-to-HTTPS redirect is a failure too.

**Workarounds:**

* Register the final HTTPS URL, not one that redirects.
* Acknowledge first and download videos or update records afterwards.

**How Hookdeck Can Help:** Hookdeck answers Argil immediately and delivers to your service with its own timeout and [retry policy](/docs/retries).

### Three days, then disabled

**The Problem:** After a long outage, Argil stops sending events altogether.

**Why It Happens:** Argil retries for 3 days and then disables the webhook and emails the workspace owner. Returning `410 Gone` also disables it at once, so a router that answers `410` for an unknown path can switch the webhook off by accident.

**Workarounds:**

* Never return `410` from the webhook route unless you mean to unsubscribe.
* After an incident, list your webhooks and update any that were disabled. An update re-enables it.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as it arrives, so your downtime doesn't count against Argil's 3 days, and it keeps the events until your service is back.

### Duplicates and out-of-order events

**The Problem:** A render is processed twice, or a failure arrives after you've already marked the video as done.

**Why It Happens:** Argil delivers at least once and makes no ordering promise.

**Workarounds:**

* Deduplicate on `webhook-id`.
* Store the latest state per `videoId` or `avatarId`, and fetch the video from the API when two events disagree.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated `webhook-id` values before they reach you.

## Best practices

### Keep the body raw until it's verified

The signature covers the exact bytes Argil sent. Mount `express.raw()` on the route, use `await request.text()` in a Next.js route handler, or `await request.body()` in FastAPI, and parse only after the check passes.

### Watch `renderCostRefunded`

A failed render with `renderCostRefunded: false` means credits were spent and not returned. Route those to someone who can raise it with Argil rather than retrying the render silently.

### Don't assume the one-shot callback is signed

`POST /videos/{id}/render` accepts its own `callbackUrl`, which receives the same video fields. Argil's docs don't say whether those calls are signed, so check a real request before you send them through the same verifier, or rely on registered webhooks instead.

### Process asynchronously

Download large video files in a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Argil's webhooks are few and simple: four events about renders and avatar trainings, created through the API and signed per Standard Webhooks. The details that matter are operational. Save the secret when you create the webhook, recreate any legacy unsigned ones, answer within 10 seconds without redirects, and never let your route return `410` by accident.

[Hookdeck Event Gateway](https://hookdeck.com) can sit between Argil and your service, verify each delivery and hold it through your outages, so a long incident doesn't end with a disabled webhook. [Sign up for Hookdeck](https://dashboard.hookdeck.com/signup) to try it with your Argil account.