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

# Webhooks

> Receive notifications about staff-issued refunds and cardholder disputes.

Webhooks notify your application when GroGov staff refund a payment or a cardholder dispute changes:

| Event | When |
| - | - |
| `payment.refunded` | A GroGov staff member refunded one of your payments (fully or partly) in the dashboard. `data.reversalType` is `void` when a full refund ran before the batch settled |
| `payment.disputed` | A cardholder dispute (chargeback) on one of your payments opened, or its status or stage changed |

Your own `POST /payments/{id}/void` and `/refund` calls do not trigger webhook events.

## Registering an endpoint

Email `support@grogov.us` with the HTTPS URL that should receive events. GroGov registers the endpoint and provides its signing secret separately. The signing secret starts with `whsec_`. The signing secret is shown only once. Store it securely and use it to verify webhook signatures. The URL must use `https` and resolve to a public address. You can have more than one endpoint; each gets every event. To change or remove one, email support.

## What you receive

GroGov sends a `POST` with a JSON body and these headers:

| Header | Value |
| - | - |
| `GroGov-Signature` | `t=<unix seconds>,v1=<hex signature>`. See [Verifying the signature](#verifying-the-signature) |
| `GroGov-Event-Id` | The event's `id` |
| `GroGov-Event-Type` | The event's `type` |

```json payment.refunded theme={null}
{
  "id": "evt_3f2a9c0d1b8e4f6a7c5d2e1f0a9b8c7d",
  "type": "payment.refunded",
  "createdAt": "2026-10-07T14:03:11.402Z",
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "reversalType": "refund",
    "amountCents": 2700,
    "currency": "USD",
    "reason": "Duplicate payment",
    "initiatedBy": "staff"
  }
}
```

```json payment.disputed theme={null}
{
  "id": "evt_9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d",
  "type": "payment.disputed",
  "createdAt": "2026-10-07T15:20:00.000Z",
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "disputeId": "6f1c2b9e-3d4a-4e8f-9b7c-2a1d0e9f8c7b",
    "status": "debited",
    "stage": "Chargeback",
    "reasonCode": "10.4",
    "reason": "Fraud - card absent environment",
    "disputedAmountCents": 10300,
    "currency": "USD",
    "openedOn": "2026-10-06",
    "dueDate": "2026-10-20"
  }
}
```

A cardholder can dispute a card payment through their card issuer. In the status descriptions below, the merchant is the payment recipient associated with the payment's processing account.

A dispute's `status` is one of:

| `status` | Meaning |
| - | - |
| `debited` | The disputed amount was taken back from the merchant; no response filed yet |
| `in_dispute` | The dispute is being contested |
| `lost` | The dispute was resolved against the merchant |
| `recovered` | The disputed amount was returned to the merchant |

`stage` is the processor's name for the latest step in the dispute. You get a new `payment.disputed` event each time `status` or `stage` changes.

`data.id` is the payment's `id`, the same `id` your create-payment call returned. The event's own top-level `id` (`evt_…`) identifies the event and is for deduplication. Amounts are integer cents. Call `GET /payments/{id}` with `data.id` for the payment's current state. The full schemas are in the API Reference.

## Verifying the signature

Verify the signature of every webhook request before processing the event. The signature is a hex HMAC-SHA256, keyed with your endpoint's secret, of `<t>.<raw request body>`. Use the **raw** body bytes: re-serialised JSON will not match.

```ts Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.GROGOV_WEBHOOK_SECRET!;

app.post("/grogov-webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("GroGov-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.${req.body}`).digest("hex");

  const valid =
    typeof parts.v1 === "string" &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // reject replays older than 5 minutes
  if (!valid || !fresh) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString());
  // Queue the event for processing before acknowledging receipt.
  res.sendStatus(200);
});
```

## Retries and duplicates

Respond with any `2xx` status within **10 seconds**. Anything else (another status, a timeout, a redirect, a connection error) counts as a failure, and the delivery is retried about every **2 minutes**, up to **5 attempts** in total. After that GroGov holds the event and can redeliver it once your endpoint is available. Contact GroGov support to request redelivery.

* **Deduplicate using the event's `id`.** A retried or redelivered event keeps its `id`, and a delivery can arrive more than once.
* **Do not rely on order.** Events can arrive out of order; read the payment with `GET /payments/{id}` (using `data.id`) when order matters.
* **Acknowledge receipt before processing.** Queue the event before returning `2xx`, then process it asynchronously to avoid delivery timeouts.
* **Reconcile payments periodically.** Webhook delivery can fail, and events can be missed. Treat `GET /payments` as the source of truth and reconcile against it periodically.

## Testing

Ask for a sandbox webhook endpoint when you request [sandbox credentials](/guides/sandbox-keys-and-portals). To trigger `payment.refunded`, ask GroGov support to reverse a sandbox payment from the dashboard. Disputes cannot be triggered in the sandbox.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.