# Alert API and generic webhook

If your tool is not in the catalog, or you want to raise alerts from your own code, use the **API** integration. It accepts a small JSON body in which only a summary is required, and it can also acknowledge and resolve alerts. Its body is compatible with the Events v2 shape used by other paging tools, so a tool that already speaks it only needs a new URL and key.

## Set up the API integration

:::steps
### Create the integration
Go to **On-Call → Integrations → + Add Integration**, choose **API**, choose an **Escalation Policy** and click **Create Integration**.
### Copy the URL and key
Open the integration. The **Webhook URL** is `https://evohub.io/ingest/api?key=<key>`; the **API Key** is the key on its own.
### Send a test event
Run the `curl` example below with your key and check that an alert appears in **On-Call → Alerts**.
:::

## Send an event

`POST https://evohub.io/ingest/api` with a JSON body. Send the integration key in whichever way your tool makes easiest:

- `"routing_key"` in the body,
- `?key=` in the URL, or
- an `Authorization: Bearer <key>` header.

:::code-group
```bash [Minimal trigger]
curl -X POST 'https://evohub.io/ingest/api' \
  -H 'Authorization: Bearer YOUR_INTEGRATION_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"summary": "Database primary is down"}'
```

```bash [Full trigger]
curl -X POST 'https://evohub.io/ingest/api' \
  -H 'Content-Type: application/json' \
  -d '{
    "routing_key": "YOUR_INTEGRATION_KEY",
    "event_action": "trigger",
    "dedup_key": "db-primary-down",
    "payload": {
      "summary": "Database primary is down",
      "source": "db-01.prod.acme.example",
      "severity": "critical",
      "component": "postgres",
      "group": "payments",
      "custom_details": { "region": "eu-central" }
    }
  }'
```

```bash [Resolve]
curl -X POST 'https://evohub.io/ingest/api' \
  -H 'Content-Type: application/json' \
  -d '{
    "routing_key": "YOUR_INTEGRATION_KEY",
    "event_action": "resolve",
    "dedup_key": "db-primary-down"
  }'
```
:::

### Request fields

You can send the alert details nested in `payload` (Events v2 style) or as flat fields at the top level. A flat field fills the matching `payload` field when that one is empty.

| Field | Required | Description |
| --- | --- | --- |
| `routing_key` | No* | The integration key. *Required unless sent as `?key=` or a bearer token. |
| `event_action` | No | `trigger` (default), `acknowledge` or `resolve`. |
| `dedup_key` | For acknowledge and resolve | Identifies the alert. On a trigger without one, EvoHub derives a key from the summary and source and returns it. |
| `payload.summary` / `summary` / `title` | For trigger | The alert's title. |
| `payload.severity` / `severity` | No | `critical`, `high`, `medium`, `low` or `info`. The Events v2 values are accepted too: `error` is read as `high` and `warning` as `medium`. Anything else, or nothing, is `medium`. |
| `payload.source` / `source` | No | Where the problem is, for example a host name. Stored as the `source` label. |
| `description` / `payload.description` | No | The alert's description. |
| `payload.component`, `payload.group`, `payload.class` | No | Stored as labels of the same name. |
| `payload.custom_details` | No | An object; each entry becomes a label, its value as text. |
| `payload.timestamp`, `client`, `client_url` | No | Accepted for compatibility and ignored. The alert is stamped when it arrives. |

### Response

A processed event returns `202`:

```json
{
  "data": {
    "status": "success",
    "message": "Event processed",
    "dedup_key": "db-primary-down"
  },
  "success": true
}
```

Keep the `dedup_key` if you let EvoHub derive it — you need it to resolve the alert later.

A request that was not processed returns the same envelope with `"status": "invalid event"` and a `message`:

| Status | When | Example `message` |
| --- | --- | --- |
| `400` | The body is not JSON, a trigger has no summary, an acknowledge or resolve has no `dedup_key`, or `event_action` is unknown. | `summary is required` |
| `401` | No key was sent, or it names no enabled integration. | `invalid routing_key` |
| `500` | The alert could not be created, acknowledged or resolved. | `failed to create alert` |
| `503` | The key could not be checked right now. Retry after the `Retry-After` seconds. | `could not verify routing_key right now; retry later` |

### How events behave

- **Trigger** opens an alert and starts the integration's escalation policy. A trigger whose `dedup_key` matches an alert that is still open does not open a second one; it is recorded as **Retriggered** on the existing alert.
- **Acknowledge** acknowledges the open, triggered alert with that `dedup_key`.
- **Resolve** resolves the open alert with that `dedup_key`.
- Acknowledging or resolving a `dedup_key` that has no open alert does nothing and still returns `202`.
- The integration's escalation policy always applies; the request cannot choose another one.

## Generic alert webhook

EvoHub also accepts a simpler, flat alert format at `/ingest/alerts`. Create a **Webhook** integration for it (the key of an **API** integration works too):

:::code-group
```bash [Trigger]
curl -X POST 'https://evohub.io/ingest/alerts?key=YOUR_INTEGRATION_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Disk almost full on web-03",
    "description": "/var is at 93%",
    "severity": "high",
    "fingerprint": "web-03-disk-var",
    "labels": { "host": "web-03.acme.example" },
    "annotations": { "runbook": "https://wiki.acme.example/disk" }
  }'
```

```bash [Resolve]
curl -X POST 'https://evohub.io/ingest/alerts?key=YOUR_INTEGRATION_KEY' \
  -H 'Content-Type: application/json' \
  -d '{ "fingerprint": "web-03-disk-var", "status": "resolved" }'
```
:::

| Field | Required | Description |
| --- | --- | --- |
| `title` | To open an alert | The alert's title. |
| `description` | No | The alert's description. |
| `severity` | No | `critical`, `high`, `medium`, `low` or `info`, in any letter case. `error` is read as `high` and `warning` as `medium`. Anything else, or nothing, is `medium`. |
| `fingerprint` | No | Identifies the alert within this integration. Repeats while it is open are recorded as **Retriggered**. Without one, EvoHub derives it from the title, the instance, every label and `alert_service` — see [Alerts without a fingerprint](#alerts-without-a-fingerprint). |
| `status` | No | `resolved`, `ok`, `online` or `up` resolves the open alert with the same fingerprint (or, without one, the same title, instance, labels and `alert_service`) instead of opening one. |
| `source` | No | Kept as the `source` label. The alert's source is always **webhook**. |
| `labels`, `annotations` | No | String-to-string maps, shown on the alert and usable in the voice template. |
| `escalation_policy_id` | No | Used only when the integration has no escalation policy of its own. |

The key goes in `?key=` only. A missing `title` (on a trigger), or a resolve with neither a `fingerprint` nor a `title`, returns `400` with the code `VALIDATION_FAILED` and the field in `details`; a body that is not a JSON object returns `400` with `INVALID_BODY`; a bad key returns `401` with `INVALID_KEY`. A body larger than 1 MiB returns `413` with `PAYLOAD_TOO_LARGE`. A successful call returns `200` with `{"data": {"received": 1, "created": 1, "resolved": 0}, "success": true}`; `resolved` is the number of alerts actually resolved, so a resolve that matched no open alert answers `200` with `"resolved": 0`. This format cannot acknowledge; use the API events above for that.

### Alerts without a fingerprint

When a body has no `fingerprint`, EvoHub derives one from the title, the instance (`alert_instance` or the `instance` label), every label as `key=value`, and `alert_service`. Description, severity, `alert_date` and annotations are not part of it.

- Sending exactly the same alert again retriggers the open alert instead of opening a second one. (Before October 2026 every such send opened a new alert.)
- Alerts that differ in any label — another `host`, another `region` — are separate alerts.
- A resolve without a `fingerprint` must carry the same title, instance, labels and `alert_service` the alert was opened with.

If you want to choose the grouping yourself, send a `fingerprint`.

### Fingerprints belong to the integration

A fingerprint, sent or derived, only matches alerts opened through the same integration. Two integrations in one organization that send the same fingerprint open separate alerts, and neither can resolve the other's. EvoHub stores the fingerprint prefixed with the integration's ID; send it to this webhook as you always have, without the prefix.

### Limits

Titles are kept to 250 characters and descriptions to 4,000. At most 50 labels and 50 annotations are kept (the first 50 by key), with keys up to 64 characters and values up to 512. Longer text is cut, not refused, so the alert still opens.

### Moving from Parny

The generic webhook also reads the field names Parny's webhook uses. If a tool is already set up to send Parny's JSON, replace the Parny URL with your EvoHub webhook URL and leave the body as it is.

| Parny field | Becomes |
| --- | --- |
| `alert_name` | Title |
| `alert_description` | Description |
| `alert_severity` | Severity (`CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `INFO` in any case) |
| `alert_instance` | The `instance` label |
| `alert_service` | The `service` label. On-Call does not route by service; use the integration's escalation policy for that. |
| `alert_date` | The `date` label. The alert is stamped when it arrives. |
| `alert_status` | `resolved` resolves the alert with the same `alert_name`, `alert_instance` and `alert_service` (and labels, if you send any). |

```bash
curl -X POST 'https://evohub.io/ingest/alerts?key=YOUR_INTEGRATION_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "alert_name": "High CPU",
    "alert_severity": "HIGH",
    "alert_instance": "es-01",
    "alert_service": "Elasticsearch",
    "alert_description": "CPU above 95% for 10 minutes",
    "alert_date": "10.10.2026"
  }'
```

Send the same body with `"alert_status": "resolved"` to resolve it. When a body carries both an EvoHub field and its Parny counterpart (for example `title` and `alert_name`), the EvoHub field wins.

## Tips

- Choose a stable `dedup_key` (or `fingerprint`) per problem, such as `<host>-<check>`, so repeats while the problem lasts do not open new alerts and your resolve finds the right alert.
- Send a resolve when your check passes again. Without one, the alert stays open until someone resolves it.
- Only new alerts count toward usage; retriggers of an open alert do not. See [How billing works](https://docs-dev.evohub.io/how-billing-works.md).

## Related

- [Integrations overview](https://docs-dev.evohub.io/integrations-overview.md)
- [Alerts and incidents](https://docs-dev.evohub.io/alerts-and-incidents.md)
- [Escalation policies](https://docs-dev.evohub.io/escalation-policies.md)
