# How to set up webhooks on AccessGrid

June 10, 2026

Auston Bunsen

## Overview

Webhooks let AccessGrid push real-time event notifications to your own backend the moment something happens — a pass is issued, a card template is published, an account balance drops below threshold, and so on. Instead of polling our API, you register an HTTPS endpoint and we `POST` a [CloudEvents](https://cloudevents.io/)-formatted payload to it as events occur.

This guide walks through creating a webhook, completing the one-time verification handshake, authenticating deliveries, and reading the payloads you'll receive.

## Creating a webhook

In the AccessGrid console, open **Webhooks** from the left navigation and create a new webhook. You'll provide:

| Field | Description |
| --- | --- |
| Name<br>Copy | A human-readable label (e.g. `Production badge sync`).<br>Copy |
| URL<br>Copy | The HTTPS endpoint we deliver to. Must be a valid, publicly resolvable `http`/`https` URL.<br>Copy |
| Authentication<br>Copy | How deliveries are authenticated — Bearer token (default) or mTLS.<br>Copy |

Once saved, the webhook is created in a **Pending** state and a **Private Key** is generated for you. Deliveries do **not** start flowing yet — the endpoint must first prove it owns the URL by completing the verification handshake described below.

**Security note:** The private key shown on this screen is the bearer token we'll send with every delivery. Treat it like a password — copy it into your endpoint's configuration and never commit it to source control.

## Verifying your endpoint

Before AccessGrid sends any real events, your endpoint must prove ownership by completing a challenge–response handshake. This prevents events from being delivered to a URL you don't actually control. It works in a few steps.

AccessGrid sends a `POST` to your URL. The challenge token is carried in the **`X-AccessGrid-Webhook-Challenge`** header (it's also mirrored in the JSON body for convenience):

```http
POST /your-webhook-endpoint HTTP/1.1

Content-Type: application/json
User-Agent: AccessGrid-Webhooks/1.0
X-AccessGrid-Webhook-Challenge: 9f2c1ab7e4d8...
# the challenge token
{ "challenge": "9f2c1ab7e4d8..." }
```

Your endpoint must respond with **HTTP 200** and **echo the token back** as JSON:

```json
{ "challenge": "9f2c1ab7e4d8..." }
```

Here is some sample code on how a verification would work:

```javascript
// Express
app.post("/your-webhook-endpoint", express.json(), (req, res) => {
// Verification handshake: the challenge token arrives in the request header
const challenge = req.get("X-AccessGrid-Webhook-Challenge");
if (challenge) {
    return res.status(200).json({ challenge });
}
// ...otherwise handle a normal event delivery (see section 4)
res.sendStatus(200);
});
```

Once we receive a matching echo, the webhook flips to **verified** and deliveries begin automatically.

## SSRF protection

For your safety and ours, the destination URL is resolved and validated to a **public IP** before any request — including the verification handshake — is sent. URLs that resolve to loopback, RFC 1918 private ranges, link-local, CGNAT, or other reserved ranges are rejected. We also pin the connection to the validated IP to prevent DNS-rebinding.

Testing with [webhook.site](https://webhook.site/)? That host is trusted and skips the challenge handshake (it still goes through public-IP validation), so webhooks pointed at it verify automatically — handy for quick experiments.

## Authenticating deliveries

Every delivery is authenticated so your endpoint can confirm the request genuinely came from AccessGrid. We have two authentication mechanisms: Bearer tokens and mutual TLS.

**Bearer token (default)**

We send your webhook's private key in the `Authorization` header:

```http
Authorization: Bearer Fj6cXm13pWZxTPKtfPVMcDddU6joAQVfmMZLUai7uq12uqwvSLsZaapY5HFe1PjE
```

Your endpoint should compare this against the stored private key using a **constant-time comparison** and reject anything that doesn't match:

```ruby
expected = ENV["ACCESSGRID_WEBHOOK_KEY"]
provided = request.headers["Authorization"].to_s.delete_prefix("Bearer ")

unless ActiveSupport::SecurityUtils.secure_compare(provided, expected)
halt 401
end
```

**Mutual TLS**

If you select **mTLS** authentication, AccessGrid presents a client certificate on each delivery, which your server validates at the TLS layer. This is the strongest option for high-security environments. Certificates are issued for you and can be rotated; after a rotation, both the old and new certificate are accepted for a 7-day grace period.

## The delivery payload

Deliveries are sent as **CloudEvents v1.0** in the JSON format. The headers on every delivery:

```http
POST /your-webhook-endpoint HTTP/1.1

Content-Type: application/cloudevents+json
User-Agent: AccessGrid-Webhooks/1.0
Authorization: Bearer <your-private-key>     # bearer-token webhooks only
```

The body is a CloudEvents envelope. The standard envelope fields:

| Field | Description |
| --- | --- |
| `specversion`<br>Copy | Always `"1.0"`.<br>Copy |
| `id`<br>Copy | Unique event ID. Use this for idempotency / de-duplication.<br>Copy |
| `source`<br>Copy | `/accessgrid/customer` for real events, `/accessgrid/test` for test sends.<br>Copy |
| `type`<br>Copy | The event type, e.g. `ag.access_pass.issued`.<br>Copy |
| `dataschema`<br>Copy | URI of the JSON schema describing `data` for this event type and version.<br>Copy |
| `time`<br>Copy | ISO-8601 timestamp of the event.<br>Copy |
| `data`<br>Copy | The event-specific payload.<br>Copy |

### Example: `ag.access_pass.issued`

```json
{
"specversion": "1.0",
"id": "a-u_2Ui5g9zA1Q",
"source": "/accessgrid/customer",
"type": "ag.access_pass.issued",
"dataschema": "https://api.accessgrid.com/schemas/access_pass/v1.0.3.json",
"time": "2026-06-10T20:16:29Z",
"data": {
    "id": "access_pass_id",
    "card_template_id": "tpl_123",
    "state": "active",
    "full_name": "John Doe",
    "employee_id": "EMP-001",
    "title": "Engineer",
    "start_date": "2026-01-01T00:00:00Z",
    "expiration_date": "2027-01-01T00:00:00Z",
    "card_templates": [
      {
        "id": "tpl_123",
        "platform": "apple",
        "protocol": "desfire",
        "name": "Employee Badge"
      }
    ]
}
}
```

### Example: `ag.landing_page.created`

```json
{  "specversion": "1.0",
  "id": "lp_evt_002",
  "source": "/accessgrid/customer",
  "type": "ag.landing_page.created",
  "dataschema": "https://api.accessgrid.com/schemas/landing_page/v1.0.1.json",
  "time": "2026-06-10T12:30:00Z",
  "data": {
      "landing_page_id": "lp_xyz"
  }}
```

### Example: `ag.account_balance.low`

```json
{  "specversion": "1.0",
  "id": "bal_evt_003",
  "source": "/accessgrid/customer",
  "type": "ag.account_balance.low",
  "dataschema": "https://api.accessgrid.com/schemas/account_balance/v1.0.1.json",
  "time": "2026-06-10T11:00:00Z",
  "data": {
      "account_id": "acct_123",
      "organization_name": "Acme Corp",
      "current_balance": 50.0,
      "threshold": 100.0,
      "amount_below_threshold": 50.0
  }}
```

The `dataschema` URI points to the JSON Schema for that event type, so you can validate payloads programmatically and see exactly which fields a given version provides.

## Retries and delivery guarantees

A delivery is considered **successful** when your endpoint returns **HTTP 200 or 201**. Anything else (or a connection error/timeout) triggers an automatic retry with exponential backoff:

| Attempt | Delay after previous |
| --- | --- |
| 1<br>Copy | immediate<br>Copy |
| 2<br>Copy | 30 seconds<br>Copy |
| 3<br>Copy | 2 minutes<br>Copy |
| 4<br>Copy | 5 minutes<br>Copy |
| 5<br>Copy | 15 minutes<br>Copy |
| 6<br>Copy | 30 minutes<br>Copy |
| 7<br>Copy | 1 hour<br>Copy |

Retries stop once the event is older than the **6-hour retry window**, or after the schedule is exhausted. Because retries are expected, your handler should be **idempotent** — de-duplicate on the CloudEvents `id` so a redelivered event isn't processed twice.

Every attempt — success or failure — is recorded under **Webhook attempts**, showing the event type, event ID, response status, and timestamp. Use this to debug what your endpoint returned.

## Sending a test event

Once a webhook is **Verified**, you can fire a synthetic event at it without waiting for real activity. Pick an event from the **Select event…** dropdown and click **Send Test**.

Test deliveries:

- Carry `source: "/accessgrid/test"` and a test flag, and are labeled with a **Test** badge in the attempts list.
- Contain realistic synthetic data so you can exercise your parsing logic.
- Are sent **immediately and inline** with **no retries** — what you see is the single result of that one attempt.

This is the fastest way to confirm your endpoint parses payloads and returns `200 OK` end-to-end before relying on production events.

## Summary

That's the full lifecycle of an AccessGrid webhook — create the endpoint, prove ownership through the challenge–response handshake, authenticate each delivery, and parse the CloudEvents payloads as mobile wallet events fire on your account. Before you go live, it's worth running through the essentials one more time:

- Create the webhook with your HTTPS endpoint and choose Bearer token or mTLS.
- Store the private key securely in your endpoint's config.
- Implement the challenge handler — read the token from the `X-AccessGrid-Webhook-Challenge` header and echo it back with HTTP 200.
- Wait for the status to flip to **Verified** (or hit **Resend verification**).
- Validate the `Authorization` bearer token (or client cert) on every delivery.
- Return `200`/`201` quickly; de-duplicate on the CloudEvents `id`.
- Fire a **Send Test** to confirm the full round-trip.

Once those boxes are checked, your integration will receive events in real time with no polling required.
