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

# Webhooks

> Get events pushed to your app or a Discord channel the moment they happen.

Instead of asking the API what changed every few seconds, the server can send events to a URL of yours. The server owner sets this up in the console under **API → Webhook**.

There are two kinds of webhook URL:

* **A Discord webhook link.** Events arrive as tidy embeds. You don't need to write any code.
* **Your own HTTPS endpoint.** Events arrive as signed JSON.

## Events

| Event | Sent when |
| - | - |
| `player.join` | A player joins. |
| `player.leave` | A player leaves. |
| `player.kill` | A player dies. Includes the killer when another player did it within the last 15 seconds. |
| `modcall.open` | A player calls for a moderator (`!mod`, `!help` or `/mod`). |
| `modcall.claim` | A staff member responds to a mod call. |
| `staff.command` | A staff member runs a command in game. |
| `api.command` | A command sent through the API finishes. Includes whether it worked. |
| `emergency.call` | A new 000 call or automatic alert appears on the MDT. |
| `webhook.test` | Someone pressed **Send test** in the console. |

The owner picks which events to send. Each webhook gets the ones it's subscribed to.

## Payload

Events are batched. One request can carry up to 50, oldest first.

```http theme={null}
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: ERA-Webhooks/1.0 (+https://apidocs.eraus.gg)
X-ERA-Timestamp: 1793875464
X-ERA-Signature: v1=5b0c3c9f8e0d7b...
```

```json theme={null}
{
  "server": { "id": "k3j9x0abq2mn", "name": "Sydney Metro RP" },
  "events": [
    {
      "id": 4182,
      "type": "player.kill",
      "timestamp": 1793875461,
      "data": {
        "victim": { "id": 2210934, "name": "reckless_ricky" },
        "killer": { "id": 1458291, "name": "jess_writes" }
      }
    },
    {
      "id": 4183,
      "type": "modcall.open",
      "timestamp": 1793875463,
      "data": { "caller": { "id": 98123, "name": "newplayer01" }, "callId": 12 }
    }
  ]
}
```

`id` goes up by one for each event on a server. Use it to drop duplicates. It's the same `id` you see in the [events feed](https://apidocs.eraus.gg/events.md).

### Event data

| Event | `data` |
| - | - |
| `player.join`, `player.leave` | `player` |
| `player.kill` | `victim`, `killer` (or `null`) |
| `modcall.open` | `caller`, `callId` |
| `modcall.claim` | `caller`, `moderator`, `callId` |
| `staff.command` | `player`, `command` |
| `api.command` | `id`, `command`, `key` (key name), `ok`, `message` |
| `emergency.call` | `kind` (`000` or `alert`), `caller` (or `null`), `headline`, `details`, `location`, `x`, `z` |

A player is `{ "id": <user ID>, "name": "<username>" }`.

## Verify the signature

When the owner saves a non-Discord webhook, the console shows a signing secret (`whsec_...`) once. Each request is signed with it:

```
signature = HMAC_SHA256(secret, timestamp + "." + raw_body)  as hex
```

Check it against the raw body before you parse anything, and refuse requests older than 5 minutes.

```javascript Node.js theme={null}
import crypto from "node:crypto";

export function verify(rawBody, headers, secret) {
  const ts = headers["x-era-timestamp"];
  const sig = (headers["x-era-signature"] || "").replace(/^v1=/, "");
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
```

```python Python theme={null}
import hmac, hashlib, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts = headers.get("X-ERA-Timestamp", "")
    sig = headers.get("X-ERA-Signature", "").removeprefix("v1=")
    if not ts or abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)
```

Discord webhooks aren't signed: Discord checks its own link.

## Delivery

* Reply with any `2xx` within **5 seconds**. Do the slow work after replying.
* A failed delivery is retried after about 5 seconds, 30 seconds, 2 minutes, then 10 minutes, then dropped. A `4xx` reply other than `408` or `429` isn't retried.
* Redirects aren't followed.
* After **25 failures in a row** the webhook is paused, so a dead endpoint isn't hammered. The console shows it as paused. Saving the webhook again turns it back on.
* Events usually reach you within 3 to 10 seconds. They come in batches with each sync, not one request per event.
* If more than 200 batches are waiting, the oldest are dropped. The [events feed](https://apidocs.eraus.gg/events.md) still has everything from the last 7 days.

## Rules for the URL

* Must be `https://` on port 443, with no username or password in it.
* No IP addresses, `localhost` or internal hostnames, and no `eraus.gg` addresses.
* One webhook per server. Saving a new one replaces it. The URL is never shown back in the console, only its host name.


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