# Guide to Argyle Webhooks: Features and Best Practices

Argyle connects to a person's payroll or gig platform account, with their consent, and returns their employment, income and pay data to lenders, landlords and fintech apps. Almost everything Argyle does happens after the user has left your flow: an account finishes connecting, years of paystubs sync in the background, a W-2 is scanned, a verification report becomes ready. Webhooks are how your backend learns that each of those things has happened, so you can fetch the data or move a loan file forward without polling.

This guide covers how Argyle subscriptions are scoped, the event families and their sync events, how to sign deliveries with a secret you choose, and the single retry that makes an Argyle receiver less forgiving than most.

## What are Argyle webhooks?

An Argyle webhook subscription has a name, an HTTPS URL, a list of events, an optional secret and an optional `include_resource` setting. Argyle stresses that subscriptions "apply to every matching event available to the API key" and are not scoped to one user or account. One subscription for `paystubs.added` fires for every user you have ever connected.

Argyle does not generate a signing secret. You supply one when you create the subscription, and only then does each delivery carry `X-Argyle-Signature`, a hex HMAC-SHA512 of the raw body. A subscription without a secret sends no signature header at all.

## Argyle webhook features

| Feature | Details |
| --- | --- |
| Configuration | Argyle Console, or `POST /v2/webhooks` |
| Scope | Every user and account under the API key |
| Signature | hex HMAC-SHA512 |
| Signature header | `x-argyle-signature` |
| Signed content | The raw request body, unparsed |
| Secret | Optional, chosen by you, set on the subscription |
| Body | JSON |
| Event routing | Body field `event` |
| Response window | `200` within 10 seconds |
| Retries | One retry after 30 seconds, only for `429`, `500`, `502`, `503` or `504` |
| Source IPs | `34.27.251.226`, `34.122.50.253`, `35.188.163.115`, `35.226.53.251` |
| Delivery log | No bodies are logged. The subscription's `last_sent_at` shows the last attempt |

## Events and payloads

The top-level `event` field names the subscribed event as `<resource>.<action>`. Argyle documents 46 events. These are the account, gig, identity and Item events:

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `accounts.added` | A new account is added | Track a user's linked payroll accounts |
| `accounts.connected` | An account connects | Start fetching data |
| `accounts.failed` | An account connection fails | Prompt the user to retry |
| `accounts.removed` | An account is removed | Clean up stored data |
| `accounts.updated` | An account changes | Refresh connection state |
| `gigs.added`, `gigs.updated`, `gigs.removed` | Gig data changes | Sync gig records |
| `gigs.partially_synced`, `gigs.fully_synced` | Gig sync progresses or completes | Know when data is ready |
| `identities.added`, `identities.updated` | Identity data changes | Refresh identity records |
| `items.updated`, `items.removed` | A supported employer or platform changes | Refresh your item list |

The other families follow the same pattern:

* **Verifications:** `verifications.updated` when a verification's status changes, `verifications.creation_failed`, and `verifications.connection_added` when another payroll account or bank is connected after a verification.
* **Paystubs and shifts:** `added`, `updated`, `partially_synced` and `fully_synced`, plus `shifts.removed`. The `added` events fire only on a later refresh, not during the initial retrieval.
* **Documents and uploads:** `payroll_documents.*` including `ocr_completed` and `ocr_failed` for scanned 1099 and W-2 forms, and `user_uploads.*` including Ocrolus `ocr_completed`, `ocr_failed` and `ocr_authenticity`.
* **Others:** `user_forms.submitted` and `user_forms.removed`, `ratings.added` and `ratings.updated`, `vehicles.*`, `reports.ready` and `reports.generated`, and `users.fully_synced` once initial retrieval completes for every newly connected account.

For a verification product, Argyle's advice is to subscribe to `verifications.updated` and `verifications.creation_failed`. For the data APIs, subscribe to the account events and the sync events for the data you need.

### Event Payload Structure

* `event`: the subscribed event name. No header names the type.
* `name`: the subscription's own name.
* `data`: the ids of the affected objects (the resource itself is included as `data.resource` when the
  subscription config sets `include_resource`).

### Published payload example

This is the `accounts.connected` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/argyle/latest/accounts.connected.json). Argyle's reference renders it from the event's OpenAPI schema.

```json
{
  "event": "accounts.connected",
  "name": "name-for-the-webhook-subscription",
  "data": {
    "account": "0187c66e-e7e5-811c-b006-2232f00f426a",
    "user": "018051aa-f7a9-a0db-2f38-6cfa325e9d69"
  }
}

```

Without `include_resource`, the body is a pointer: the event name and some IDs. Fetch the account, paystub or verification from the API when you need its contents.

## Setting up Argyle webhooks

Generate a secret first, because Argyle won't create one for you:

```bash
openssl rand -hex 32

```

Then create the subscription in Console, or with the API:

```json
{
  "events": ["verifications.updated", "verifications.creation_failed"],
  "name": "verification-status",
  "url": "https://example.com/webhooks/argyle",
  "secret": "<your generated secret>",
  "config": { "include_resource": true }
}

```

Store the same secret in your app as `WEBHOOK_SECRET`. It is used as plain text.

Some choices to make at this point:

* **Wildcard or explicit list.** `"*"` subscribes to most events, but Argyle lists exceptions: Item webhooks are not included, partial-sync events can't set their history window through the wildcard, and events that support `include_resource` must be subscribed to individually if you want the resource object.
* **Resource included or not.** `include_resource` saves an API call per event, at the cost of larger bodies containing personal financial data.
* **One subscription or several.** Separate subscriptions with their own names make it easy to route by the body's `name` field.

### HTTP Method

Argyle's docs don't name the HTTP method. Accept GET, POST, PUT, PATCH and DELETE on your route, as the
examples do, and verify the signature first for every method.

Hookdeck's Argyle source pre-selects POST without managing the method, so any other method the sender
uses must also be allowed on the source.

### Rotating the Secret

Update the subscription's `secret` and `WEBHOOK_SECRET` together. Deliveries signed with the old
value fail with 401 in between, so do it at a quiet time.

To test against Argyle's sandbox from your machine, forward deliveries with the [Hookdeck CLI](/docs/cli):

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

```

## Securing Argyle webhooks

Read the raw body without parsing it, compute an HMAC-SHA512 with the subscription's secret, encode it as hex and compare it with `X-Argyle-Signature` using a timing-safe comparison. This is the method in [Argyle's webhook guide](https://docs.argyle.com/api-guide/webhooks), which also shows a Node sample.

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-argyle-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha512', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{128}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler accepts any method because Argyle doesn't document one. It verifies first, acknowledges an empty verified body, and stores the event before answering so the single retry is never needed.

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

function createArgyleApp(store) {
  const app = express();
  app.all('/webhooks/argyle', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    if (!req.body.length) return res.sendStatus(200);

    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { event, name, data } = parsed;
    if (typeof event !== 'string') return res.sendStatus(400);
    try {
      // e.g. accounts.connected -> fetch the account; paystubs.fully_synced -> fetch paystubs.
      await store({ event, subscription: name, user: data?.user, account: data?.account, data });
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Nothing signed is time-bound. Argyle sends no timestamp header, so idempotency is your protection against a replayed request.

Hookdeck's Argyle source type verifies `X-Argyle-Signature` as a hex HMAC-SHA512 of the raw body, using the secret you set on the subscription. Set a secret on the Argyle webhook subscription. Without one, deliveries are unsigned and fail verification. If you deliberately run an unsigned subscription, leave verification off on the source. Hookdeck answers Argyle with `200` either way, records a failed check as failed verification and does not deliver that request. No time limit applies, so replays verify too.

> **Make Argyle webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Argyle inside its 10-second window, verifies the SHA-512 signature, and keeps every verification and data event until your service has processed it.

## Argyle webhook limitations and pain points

### One retry, and only for some failures

**The Problem:** A brief outage or a slow response loses an event permanently.

**Why It Happens:** If Argyle doesn't get a `200` within 10 seconds, it retries once after 30 seconds, and only when the response was `429`, `500`, `502`, `503` or `504`. A `404` during a bad deploy or a `401` from a misconfigured secret gets no retry at all.

**Workarounds:**

* Keep the endpoint simple and fast: verify, store, return `200`.
* Watch `last_sent_at` on each subscription and compare it with what you received.
* Reconcile important state, such as verification status, against the API on a schedule.

**How Hookdeck Can Help:** Hookdeck accepts the delivery on Argyle's first attempt and stores it, then retries your service as many times as you configure, so the one-retry limit stops mattering.

### Unsigned unless you remember the secret

**The Problem:** Requests to your endpoint can't be authenticated, and nothing warned you.

**Why It Happens:** `secret` is optional on the subscription and Argyle never generates one. Created without it, the subscription sends deliveries with no `X-Argyle-Signature` header.

**Workarounds:**

* Always set `secret` when creating subscriptions, including in scripts and Terraform.
* Fail closed when the header is missing, as the verifier above does.
* Optionally, restrict the endpoint to Argyle's four static IPs as a second layer.

**How Hookdeck Can Help:** Hookdeck's Argyle source rejects unsigned deliveries once verification is on, and its request log shows any that arrive without the header.

### No body logs on Argyle's side

**The Problem:** When something goes wrong, there's no record of what Argyle sent.

**Why It Happens:** Argyle says it does not log webhook request bodies. `last_sent_at` tells you an attempt happened, not what it contained or what your server replied.

**Workarounds:**

* Log the verified body yourself, with care for the personal data it may contain.
* Keep the event name, user and account IDs from each delivery so you can reconstruct a timeline.

**How Hookdeck Can Help:** Hookdeck stores each request and your service's response, so you can inspect and replay a delivery after the fact.

### Pointers, not data

**The Problem:** A burst of `paystubs.added` events turns into a burst of API calls.

**Why It Happens:** By default the body holds only IDs. Subscribing with `"*"` also excludes resource objects, because events that support `include_resource` must be subscribed to individually.

**Workarounds:**

* Subscribe explicitly to the events you need with `include_resource` set.
* Wait for the `fully_synced` event before fetching a whole resource type, rather than reacting to every `added`.

**How Hookdeck Can Help:** Hookdeck's delivery rate limits let you cap how fast events reach your service, which in turn caps your calls back to Argyle.

## Best practices

### Wait for the sync events before reading data

After a new account connects, Argyle retrieves data in the background. `paystubs.partially_synced` means the history window you asked for is available, `paystubs.fully_synced` means everything is, and `users.fully_synced` means every newly connected account has finished. Start downstream work from these, not from `accounts.connected`.

### Verify the raw body for every method

Read the bytes untouched with `express.raw({ type: '*/*' })`, `await request.arrayBuffer()` or `await request.body()`, depending on your framework. Because the method is undocumented, run verification for whatever method arrives, and treat a verified empty body as valid.

### Deduplicate deliveries

Argyle tells you to build idempotent handlers because a delivery can arrive more than once, and there is no event ID. Use `event` plus the IDs in `data` as the key. Our [idempotency guide](/webhooks/guides/implement-webhook-idempotency) shows how to store the key with the change.

### Keep personal data out of logs

Argyle payloads, especially with `include_resource`, contain income and identity data. Log event names and IDs, not bodies, unless your logging is approved for that data.

### Process asynchronously

Return `200` within the 10-second window and do the API calls and business logic afterwards. [Asynchronous processing](/webhooks/guides/why-implement-asynchronous-processing-webhooks) keeps the response time flat however long the work takes.

## Conclusion

Argyle's webhooks tell you when payroll data is ready, but they leave a lot to the receiver: you choose the secret, you keep the logs, and you get a single retry. Set a secret on every subscription, wait for the sync events, and make sure your endpoint answers within 10 seconds every time.

[Hookdeck Event Gateway](https://hookdeck.com) adds the retries, logs and replay that Argyle leaves out, and verifies each SHA-512 signature on the way in.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Argyle webhooks reliably in minutes.