> For the complete documentation index, see [llms.txt](/llms.txt).
> A full single-fetch corpus is available at [llms-full.txt](/llms-full.txt).
---
title: Webhooks
description: Trigger workflows from external events sent by Standard Webhooks, Sentry, Stripe, GitHub, or Slack, with signature verification and idempotent delivery.
last_verified: 2026-09-29
---



A webhook lets an external system start one of your workflows by POSTing an event to AGNT5. The gateway verifies the sender's signature, turns the delivery into a durable event, and starts every workflow subscribed to it. You write a workflow and declare which event it listens for. AGNT5 handles receipt, verification, deduplication, and dispatch.

This page covers the mechanism end to end using **Standard Webhooks**, a publisher you control. To connect a specific third-party service, see [Integrations → Event sources](/docs/integrations/event-sources/overview.md), which layers provider-specific setup on top of everything here.

---

## How it works

1. You create a webhook integration in Studio and receive a URL and a signing secret.
2. The external system POSTs events to `/v1/webhooks/{source}/{integration_id}` (Studio shows the full URL).
3. The gateway verifies the HMAC signature against your secret and rejects anything that fails with `401` before any workflow runs.
4. It derives an event name of the form `{source}.{event}` and starts every workflow whose trigger matches.
5. It returns `200` once the event is durably persisted to the log, which happens before your workflow finishes running.

---

## Declare a trigger

Subscribe a workflow to a webhook event with the `webhook()` trigger:



**Python:**

```python
import json
from agnt5 import workflow, webhook, WorkflowContext

@workflow(
    name="triage_issue",
    triggers=[webhook("sentry", event="issue.created")],
)
async def triage_issue(ctx: WorkflowContext, event: dict, **_) -> dict:
    envelope = event["data"]                # the webhook envelope
    payload = json.loads(envelope["body"])  # raw request body, as a string
    issue = payload["data"]["issue"]
    ...
```

The input arrives as keyword arguments (`event`, `deployment_id`, `target_kind`, `target_ref`), so declare `event` and accept the rest with `**_`.





**TypeScript:**

```typescript
import { workflow, webhook } from '@agnt5/sdk';
import type { Context } from '@agnt5/sdk';

export const triageIssue = workflow(
  'triage_issue',
  async (ctx: Context, input: { event: { data: { body: string } } }) => {
    const envelope = input.event.data;          // the webhook envelope
    const payload = JSON.parse(envelope.body);  // raw request body, as a string
    const issue = payload.data.issue;
    // ...
  },
  { triggers: [webhook('sentry', { event: 'issue.created' })] },
);
```

The handler's input is the gateway's trigger envelope, `{ event, deployment_id, target_kind, target_ref }`, with the webhook delivery at `input.event.data`. Type the parts you use, as above.





**Go:**

```go
import (
    "encoding/json"

    "github.com/agnt5dev/sdk-go/agnt5"
)

// The gateway's trigger envelope. Declare the parts you need.
type WebhookInput struct {
    Event struct {
        Name string `json:"name"` // "sentry.issue.created"
        Data struct {
            Body string `json:"body"` // raw request body, as a string
        } `json:"data"`
    } `json:"event"`
}

err := agnt5.RegisterWorkflow(worker, "triage_issue",
    func(ctx *agnt5.Context, in WebhookInput) (TriageOutput, error) {
        var payload struct {
            Data struct {
                Issue map[string]any `json:"issue"`
            } `json:"data"`
        }
        if err := json.Unmarshal([]byte(in.Event.Data.Body), &payload); err != nil {
            return TriageOutput{}, err
        }
        issue := payload.Data.Issue
        // ...
        return TriageOutput{IssueID: issue["id"]}, nil
    },
    agnt5.WithTriggers(agnt5.WebhookTrigger("sentry", "issue.created")),
)
```

`WebhookTrigger("sentry", "issue.created")` subscribes the workflow to the event `sentry.issue.created`, the same as Python/TypeScript's `webhook()` helper. The handler's input is the gateway's trigger envelope, with the webhook delivery at `event.data`. The Go SDK has no built-in type for it, so declare a struct that mirrors the parts you need (as above), or take `map[string]any` and walk `event` → `data` → `body` yourself.



`webhook("sentry", { event: "issue.created" })` subscribes the workflow to the event `sentry.issue.created`. The `source` is one of `standard`, `sentry`, `stripe`, `github`, or `slack`. The `event` is the identifier within that source. The format differs per provider; see [Event names](#event-names) below.

A single event can fan out to multiple workflows: every workflow whose trigger matches `{source}.{event}` starts independently.

---

## What your workflow receives

Each matched workflow starts with the gateway's trigger envelope as its input. The webhook delivery itself sits at `event.data`:

```json
{
  "event": {
    "id": "req_9f3c…",
    "name": "sentry.issue.created",
    "data": {
      "_webhook": true,
      "source": "sentry",
      "integration_id": "int_abc123",
      "event_type": "sentry.issue.created",
      "idempotency_key": "req_9f3c…",
      "timestamp": 1733337600,
      "headers": { "sentry-hook-resource": "issue", "request-id": "req_9f3c…" },
      "body": "{\"action\":\"created\",\"data\":{ … }}"
    },
    "source": "sentry",
    "timestamp_ns": 1733337600000000000
  },
  "deployment_id": "dep_abc123",
  "target_kind": "deployment_id",
  "target_ref": "dep_abc123"
}
```

| Field | Meaning |
|---|---|
| `event.id` | The event id: the provider's per-delivery id when it has one (see [Delivery semantics](#delivery-semantics)), otherwise a generated UUID |
| `event.name` | The event name your trigger matched, `{source}.{event}` |
| `event.data` | The webhook envelope, described below |
| `event.source` | The webhook source (`sentry`, `stripe`, …) |
| `event.timestamp_ns` | When the gateway ingested the event, in nanoseconds since the Unix epoch |
| `deployment_id`, `target_kind`, `target_ref` | The deployment the run was dispatched to. `target_kind` is `deployment_id` or `environment_ref`, and `target_ref` is the matching id |

Inside `event.data`, `body` is the raw request body as a string. Parse it yourself so you operate on exactly the bytes that were signature-verified. `headers` keys are lowercased. `event_type` is the same value as `event.name`. `idempotency_key` is the provider's stable per-delivery id (`null` for Slack, whose `event_id` AGNT5 doesn't use; see [Delivery semantics](#delivery-semantics)). `_webhook: true` marks the payload as a webhook delivery.

An `event()`-triggered workflow receives the same trigger envelope; there, `event.data` is the published event's own payload. See [Workflows → Triggering workflows](/docs/build/workflows.md#triggering-workflows).



**Python:**

> **Python passes the input as keyword arguments.** Declare `event: dict` and accept the rest with `**_`, as in the example above. A handler that only declares `event` fails with `TypeError: got an unexpected keyword argument 'deployment_id'`.



---

## Set up an integration

You connect the sending side in Studio, once per source:

1. **Studio → Integrations → New**, then pick a source.
2. Choose the **environment** whose deployment should receive the triggers.
3. Provide the **signing secret**. For GitHub and Standard Webhooks, AGNT5 generates the secret. Copy it into the publisher. For Stripe, Slack, and Sentry, paste the secret the provider issues; generating one here would never match.
4. Copy the **webhook URL** Studio shows (`…/v1/webhooks/{source}/{integration_id}`) into the provider's webhook settings.

Provider-by-provider walkthroughs live under [Integrations → Event sources](/docs/integrations/event-sources/overview.md).

---

## Signature verification

Every delivery must carry a valid signature; AGNT5 rejects unsigned or mismatched requests with `401` before any workflow runs. Each source uses its provider's native HMAC-SHA256 scheme:

| Source | Signature header | Signed payload | Replay window |
|---|---|---|---|
| `standard` | `webhook-signature` (`v1,<base64>`) | `{id}.{timestamp}.{body}` | 5 min |
| `sentry` | `sentry-hook-signature` (hex) | raw body | n/a |
| `stripe` | `Stripe-Signature` (`t=…,v1=…`) | `{timestamp}.{body}` | 5 min |
| `github` | `X-Hub-Signature-256` (`sha256=…`) | raw body | n/a |
| `slack` | `X-Slack-Signature` (`v0=…`) + timestamp | `v0:{timestamp}:{body}` | 5 min |

Standard Webhooks and Stripe accept several signatures on one delivery, so you can rotate signing keys without dropping events.

---

## Delivery semantics

Webhook delivery is **at-least-once**. Publishers retry on any non-2xx response, and AGNT5 collapses retries using the provider's per-delivery idempotency key: `webhook-id` for Standard Webhooks, `Request-ID` for Sentry, the event `id` for Stripe, `X-GitHub-Delivery` for GitHub. A retried delivery replays the original run instead of starting a new one.

Two cases fall outside that deduplication, and either can start a workflow more than once for the same event:

- **Slack** retries are not de-duplicated. Slack sends a stable top-level `event_id` in the body, but AGNT5 doesn't use it as the idempotency key, so key your side effects off that `event_id` yourself.
- The idempotency cache is per–gateway instance and time-bounded. A retry that arrives after a gateway restart, or lands on a different gateway in a multi-node deployment, sees a cold cache.


> **Make webhook-triggered workflows idempotent.** Key your side effects off `event.data.event_type` and `event.data.idempotency_key` (or an id inside the body) so a re-delivery is a no-op. AGNT5 guarantees a workflow runs at least once per event, not exactly once.


---

## Event names

The event your trigger matches is always `{source}.{event}`. What you pass as `event` depends on the source:

| Source | `event` you pass | Resulting event name |
|---|---|---|
| `standard` | the `webhook-event` header value | `standard.<event>` |
| `sentry` | `<resource>.<action>` | `sentry.issue.created` |
| `stripe` | the body `type` | `stripe.payment_intent.succeeded` |
| `github` | `<event>.<action>`, or just `<event>` | `github.issues.opened` |
| `slack` | the event-callback type | `slack.app_mention` |

---

## Related

- [Integrations → Event sources](/docs/integrations/event-sources/overview.md): connect Sentry, Stripe, GitHub, and Slack
- [Integrations → Overview](/docs/integrations/overview.md): how integrations and signing secrets are stored
- [Workflows](/docs/build/workflows.md): what a workflow is and how it runs
