# Guide to Justt Webhooks: Features and Best Practices

[Justt](https://www.justt.ai) fights chargebacks on behalf of merchants. It pulls disputes in from your payment providers, builds and submits the evidence, handles pre-chargeback alerts from CDRN, RDR and Ethoca, and can cancel a customer's subscription in your billing system after a refund. Its webhooks report each of those steps to your own systems, so finance, support and fraud tooling see the same dispute state Justt does.

This guide walks through Justt's 11 event types and what each `data` object contains, endpoint setup, signature verification, and the places where Justt's own documentation disagrees with itself.

## What are Justt webhooks?

Justt's webhook feature isn't enabled for every customer yet; if you don't see a **Webhooks** tab, ask Justt. Where it is enabled, you add endpoints in the dashboard, and each endpoint gets the event types you choose, or all of them if you choose none. Justt suggests one endpoint per service.

Requests are JSON `POST`s with `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, signed per Standard Webhooks. The body is `{type, timestamp, data}`. The dashboard, the `msg_` message IDs and the resend and recovery tools all match what Svix provides.

## Justt webhook features

| Feature | Details |
| --- | --- |
| Availability | Not yet enabled for every customer |
| Configuration | **Account level** view > **Webhooks** tab |
| Subscription model | Selected types, or all types when none are selected |
| Signature | Standard Webhooks HMAC-SHA256 |
| Headers | `webhook-id` (`msg_...`), `webhook-timestamp`, `webhook-signature` |
| Secret | **Signing Secret** per endpoint (prefix not printed; the sample implies `whsec_`) |
| Body | `{type, timestamp, data}` |
| Event routing | Body field `type` |
| Response window | 15 seconds (the API reference says 30) |
| Retries | 8 attempts over about 27.6 hours (the API reference says up to 3) |
| Permanent failure | `410 Gone` stops retries |
| Disabling | After 5 consecutive days of failures |
| Recovery | Resend one message, or recover failures since a date |
| Transformations | Can rewrite payloads or routing before delivery |
| Source IPs | Not published |

## Events and payloads

| Field | Meaning |
| --- | --- |
| `type` | The event type, for example `chargeback.created`. This is what handlers switch on |
| `timestamp` | ISO 8601 time the event occurred |
| `data` | Event-specific object (see below) |

### Common Event Types

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `chargeback.created` | A new chargeback is received from any connected PSP | Open a case, alert the team |
| `chargeback.updated` | A chargeback is updated | Refresh your copy of the record |
| `chargeback.status.updated` | A chargeback status update is received | Track the dispute lifecycle |
| `chargeback.evidence.submitted` | Evidence is submitted to the PSP; `data.status` carries the outcome | Confirm the dispute response, retry on `failed` |
| `chargeback.accepted` | A chargeback acceptance is confirmed by the PSP; `data.status` carries the outcome | Write off the amount |
| `pre_chargeback_alert.received` | A pre-chargeback alert arrives from CDRN, RDR or Ethoca | Decide whether to refund |
| `pre_chargeback_alert.refund.succeeded` | The transaction linked to a refunded alert was refunded through the PSP | Close the alert |
| `pre_chargeback_alert.refund.failed` | A refund alert could not be processed | Refund manually |
| `pre_chargeback_alert.transaction_matched` | An inbound alert was matched to its transaction. Opt-in, enabled by Justt per account | Link the alert to your order |
| `subscription.cancel_immediately.success` | A CRM subscription (for example Chargebee or Recurly) was cancelled after a refund | Update entitlements |
| `subscription.cancel_immediately.failed` | The cancellation failed (subscription not found, already cancelled). Justt flags the alert as Action Required | Cancel by hand |

Justt calls these its supported core event types, so treat the list as a minimum.

### `data` by event family

| Events | Notable `data` fields |
| --- | --- |
| `chargeback.created`, `chargeback.updated`, `chargeback.status.updated` | The full chargeback: `id`, `chargebackId`, `referenceAccountId`, `psp`, `currency`, `amount`, `status`, `pspStatus`, `lifecycleStage`, `reasonCode`, `reason`, `reasonGroup`, `cardScheme`, `dueDate`, `winProbability` and more |
| `chargeback.evidence.submitted` | `submitEvidenceId`, `fileId`, `id`, `chargebackId`, `status` (`succeeded` or `failed`), `submittedAt`, `error` |
| `chargeback.accepted` | `id`, `chargebackId`, `status` (`succeeded` or `failed`), `acceptedAt`, `error` |
| `pre_chargeback_alert.received`, `pre_chargeback_alert.transaction_matched` | `internalAlertId`, `provider` (CDRN, RDR or Ethoca), `providerAlertId`, `alertType`, `alertTimestamp`, `arn`, `transaction`; matched events add `matchedTransaction` |
| `pre_chargeback_alert.refund.succeeded`, `pre_chargeback_alert.refund.failed` | `internalAlertId`, `providerName`, `providerAlertId`, `psp` (`pspName`, `transactionId`, plus `refundId` / `refundedDate` or `refundError`), `amount`, `currency` |
| `subscription.cancel_immediately.success`, `subscription.cancel_immediately.failed` | `subscriptionId`, `transactionId`, `alertId`, `chargebackId`, `crmProvider`; failures add `failureCode` and `failureReason` |

Here is the `chargeback.accepted` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/justt/latest/chargeback.accepted.json), taken from Justt's reference:

```json
{
  "type": "chargeback.accepted",
  "timestamp": "2022-03-15T09:39:24.514Z",
  "data": {
    "id": "04318fb0-e4ff-4712-972e-4db99b3fb23c",
    "chargebackId": "QRZ54SXB4K6TF232",
    "referenceAccountId": "04318fb0-e4ff-4712-972e-4db99b3fb23c",
    "status": "inProgress",
    "acceptedAt": "2025-01-15T12:34:56.789Z",
    "error": "Network timeout",
    "externalTransactionIdentifier": "ext_txn_123"
  }
}

```

The example's `status` of `inProgress` doesn't match the documented `succeeded` or `failed`, which is another reason to handle unexpected values rather than assume two outcomes. The body has no event ID; deduplicate on `webhook-id`.

## Setting up Justt webhooks

1. In the Justt app, switch to the **Account level** view and open **Webhooks** (`https://app.justt.ai/aggregated/webhooks`).
2. Add an endpoint with a public HTTPS URL that accepts `POST` and replies within 15 seconds.
3. Select event types. Selecting none subscribes to everything, which is useful while testing; in production choose the types you handle.
4. Save, open the endpoint, and copy its **Signing Secret** into `JUSTT_WEBHOOK_SECRET`.

Justt never prints the secret's prefix. Its sample code base64-decodes whatever follows the first `_`, which fits the `whsec_<base64>` form; check that yours starts with `whsec_`. During development, the [Hookdeck CLI](/docs/cli) can give your local receiver a public URL:

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

```

Justt also offers [webhook transformations](https://developers.justt.ai/docs/justt-webhook-transformations), which can rewrite the payload or its routing before delivery. If you use one, verify and parse what actually arrives.

**With Hookdeck.** Add a **Justt** source in Hookdeck with the endpoint's signing secret and register the source URL as the Justt endpoint. It takes `POST` only. Hookdeck returns `200` to Justt for each request, and a request that doesn't verify stays in Hookdeck as failed verification instead of reaching your service.

## Securing Justt webhooks

Justt's [signature page](https://developers.justt.ai/docs/signature-verification) prints the construction: `{webhook-id}.{webhook-timestamp}.{raw body}`, HMAC-SHA256 with the base64-decoded secret, compared as `v1,<base64>`. It stresses that the signature covers the exact raw body. The function below follows those steps and accepts any matching `v1` entry:

```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);
  });
}

```

Justt's sample on that page signs a `ping` body that uses `event_type` rather than `type`. No `ping` event is listed and every reference payload uses `type`, so route on `type` and acknowledge anything else:

```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/justt', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.JUSTT_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const event = parseJsonObject(req.body);
  if (!event) return res.sendStatus(400); // signed, but not a JSON object
  if (typeof event.type !== 'string') return res.sendStatus(400);
  const messageId = req.headers['webhook-id'];

  try {
    if (event.type === 'pre_chargeback_alert.refund.failed' || event.type === 'subscription.cancel_immediately.failed') {
      await opsQueue.add({ messageId, type: event.type, data: event.data }); // needs a person
    } else if (event.type.startsWith('chargeback.')) {
      await disputes.apply(messageId, event.type, event.data);
    }
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

```

`opsQueue` and `disputes` stand in for your own code.

Justt recommends rejecting timestamps more than 5 minutes old, and the Standard Webhooks libraries do that by default. Justt's resends and recoveries keep the original timestamp, and so do Hookdeck's retries and replays; Hookdeck itself sets no tolerance. A receiver behind Hookdeck should skip the age check, as above, and deduplicate on `webhook-id`.

> **Make Justt webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Justt's signatures, records each chargeback and alert event, and routes the ones that need action to the right team's service.

## Justt webhook limitations and pain points

### Conflicting timeout and retry figures

**The Problem:** You can't be sure how long you have to respond or how many retries to expect.

**Why It Happens:** Justt's retry page gives a 15-second timeout and 8 attempts over about 27.6 hours, with endpoints disabled after 5 days of failures. The API reference says 30 seconds and up to 3 retries.

**Workarounds:**

* Design for the stricter numbers: answer within 15 seconds and assume few retries.
* Use the dashboard's recover tool after any outage.

**How Hookdeck Can Help:** Hookdeck answers Justt at once and runs its own [retry policy](/docs/retries) to your service, so the discrepancy stops mattering.

### Failures that need a person

**The Problem:** A refund or subscription cancellation fails and nobody notices.

**Why It Happens:** `pre_chargeback_alert.refund.failed` and `subscription.cancel_immediately.failed` report work Justt couldn't finish. Justt marks the alert Action Required in its own app, but your team may not be watching there.

**Workarounds:**

* Route these events to an alerting channel, not just a database.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can send the failure types to a dedicated destination, such as an on-call webhook.

### `410` turns retries off

**The Problem:** A deploy that briefly returns `410 Gone` loses events permanently.

**Why It Happens:** Justt treats `410` as a permanent failure and stops retrying that message.

**Workarounds:**

* Never return `410` from the webhook route; use `404` or `503` for temporary problems.

**How Hookdeck Can Help:** With Hookdeck in front, Justt only ever sees Hookdeck's response, and your own errors are retried by Hookdeck.

### Secret format and sample mismatches

**The Problem:** Verification code copied from the docs doesn't match production traffic.

**Why It Happens:** The secret's prefix isn't stated, and the signature sample uses an `event_type` body that real events don't.

**Workarounds:**

* Confirm the `whsec_` prefix in the dashboard, and route on `type`.

**How Hookdeck Can Help:** Hookdeck's request log shows each Justt delivery exactly as sent, which settles questions the docs leave open.

## Best practices

### Keep the raw body intact

Use `express.raw({ type: '*/*' })`, `await request.text()` in Next.js route handlers, or FastAPI's `await request.body()`, and disable CSRF protection on the route.

### Order by `timestamp`

Chargeback updates can be retried and arrive late. Keep the latest `timestamp` per `chargebackId` and skip older updates.

### Mind `dueDate`

Chargeback events include the response deadline. If you track disputes outside Justt, alert on approaching `dueDate` values rather than waiting for the next event.

### Deduplicate on `webhook-id`

Store it with each processed event; the [idempotency guide](/webhooks/guides/implement-webhook-idempotency) shows how.

## Conclusion

Justt's webhooks give you a detailed feed of disputes, alerts and the refunds and cancellations Justt performs for you. Verify with the endpoint's `whsec_` secret, answer within 15 seconds, route the failure events to people, never return `410` by accident, and code defensively where the docs disagree.

[Hookdeck Event Gateway](https://hookdeck.com) can take that delivery burden off your service, verifying each event and routing failures separately from routine updates. [Try it for free](https://dashboard.hookdeck.com/signup) with a Justt source.