---
title: "Declare an event source"
description: "Validate outside deliveries and turn them into events."
canonical_url: "https://docs.oz0.ai/build/parts/event-sources"
---
# Declare an event source

> Validate outside deliveries and turn them into events.

For: plugin authors.

Declare an event source to turn deliveries from an outside system into events your agents can use. The edge receives a delivery at `POST /events/<tenant>/<source>`; a function in your plugin interprets it.

[Routines](https://docs.oz0.ai/build/parts/routines#events) and [flows](https://docs.oz0.ai/build/parts/flows#start-one-from-an-event) can start from these events, and running jobs can [wait for them](https://docs.oz0.ai/agents/waiting-for-events). The plugin defines the vendor's payload format, so adding a system requires a plugin rather than a core change.

For each delivery:

1. The edge checks its size and its signature, with a tenant secret only the edge reads, before anything else sees it. A forged delivery gets `401` and costs no node any work.
2. A node with your plugin calls your normalising function, an MCP function like any [tool](https://docs.oz0.ai/build/parts/tools), with the delivery. It answers with the events it found. It runs as a call that an outside event started, in the node's [sandbox](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes) where there is one.
3. The edge records each event, once per delivery ID, and routes it: to the routines and flows whose event triggers fire on it, and to the jobs that wait for such an event. Every delivery and every match is in the [audit trail](https://docs.oz0.ai/govern/audit-trail).

## Declare a source

```yaml [oz0-plugin.yaml]
apiVersion: oz0/v1
name: github-events
version: 1.0.0
runtime: python
tools:
  - id: github_events
    mcp: ["python", "-m", "github_events.server"]
    affinity: any
events:
  - name: github-events                   # POST /events/<tenant>/github-events
    description: GitHub webhook deliveries
    call: github_events.normalize         # the function that reads a delivery
    secret: GITHUB_WEBHOOK_SECRET         # a tenant secret, which only the edge reads
    signature:
      scheme: hmac-sha256
      header: X-Hub-Signature-256
      prefix: "sha256="
    delivery_header: X-GitHub-Delivery
    types: [pull_request, pull_request_review, check_suite, workflow_run, issue_comment]
```

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Meaning
    </th>
    
    <th>
      Default
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        name
      </code>
    </td>
    
    <td>
      The source's name in the tenant: lowercase letters, digits and dashes, at most 31. Two installed plugins with one source's name make its deliveries fail until one is removed, so start it with your plugin's name (<code>
        github-events
      </code>
      
      , not <code>
        github
      </code>
      
      ).
    </td>
    
    <td>
      required
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        call
      </code>
    </td>
    
    <td>
      The <code>
        &lt;tool>.&lt;function>
      </code>
      
       of this plugin that normalises a delivery
    </td>
    
    <td>
      required
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        secret
      </code>
    </td>
    
    <td>
      The tenant secret deliveries are signed with, or carry
    </td>
    
    <td>
      required
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        signature.scheme
      </code>
    </td>
    
    <td>
      <code>
        hmac-sha256
      </code>
      
      , <code>
        ed25519
      </code>
      
      , <code>
        jwt
      </code>
      
      , <code>
        token
      </code>
      
       or <code>
        plugin
      </code>
      
       (below)
    </td>
    
    <td>
      required
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        signature.header
      </code>
      
      , <code>
        prefix
      </code>
    </td>
    
    <td>
      Where the signature comes, and what comes before it
    </td>
    
    <td>
      
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        signature.encoding
      </code>
    </td>
    
    <td>
      <code>
        hex
      </code>
      
       or <code>
        base64
      </code>
      
      , for <code>
        hmac-sha256
      </code>
      
       and <code>
        ed25519
      </code>
    </td>
    
    <td>
      <code>
        hex
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        signature.timestamp
      </code>
      
      , <code>
        max_age_s
      </code>
    </td>
    
    <td>
      A header with the time the sender signed at, in Unix seconds: a delivery signed more than <code>
        max_age_s
      </code>
      
       from the edge's clock is refused, so an old one cannot be sent again. The signature must cover it: <code>
        message
      </code>
      
       holds <code>
        {timestamp}
      </code>
      
      , and lint refuses a timestamp with a <code>
        token
      </code>
      
       or without <code>
        {timestamp}
      </code>
      
       in <code>
        message
      </code>
      
      , since a time nobody signed can be set to now
    </td>
    
    <td>
      none; 300
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        signature.message
      </code>
    </td>
    
    <td>
      What the sender signs, of <code>
        {body}
      </code>
      
       and <code>
        {timestamp}
      </code>
      
      , such as <code>
        v0:{timestamp}:{body}
      </code>
    </td>
    
    <td>
      <code>
        {body}
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        delivery_header
      </code>
    </td>
    
    <td>
      The header with the sender's ID for one delivery, recorded with it (<code>
        sender_delivery
      </code>
      
       in its <code>
        event.delivery
      </code>
      
       entry). No signature covers it, so it never names the delivery: the body's SHA-256 does
    </td>
    
    <td>
      <code>
        Idempotency-Key
      </code>
      
      , then <code>
        X-Request-Id
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        types
      </code>
    </td>
    
    <td>
      The event types your function gives, for people and lint
    </td>
    
    <td>
      
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        max_body_kb
      </code>
    </td>
    
    <td>
      The largest delivery taken
    </td>
    
    <td>
      1024
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        replies
      </code>
    </td>
    
    <td>
      What the edge answers at once, <a href="https://docs.oz0.ai/build/parts/event-sources/delivery-binding#answer-at-once">
        below
      </a>
    </td>
    
    <td>
      
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        bind
      </code>
    </td>
    
    <td>
      What the edge checks of your function's events against the delivery, <a href="https://docs.oz0.ai/build/parts/event-sources/delivery-binding#bind-events-to-the-delivery">
        below
      </a>
    </td>
    
    <td>
      none: nothing matches on the events' fields
    </td>
  </tr>
</tbody>
</table>

The signature schemes name no vendor:

<table>
<thead>
  <tr>
    <th>
      Scheme
    </th>
    
    <th>
      The edge checks
    </th>
    
    <th>
      For example
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        hmac-sha256
      </code>
    </td>
    
    <td>
      An HMAC-SHA256 of the raw body with the secret as its key, in <code>
        header
      </code>
      
       after <code>
        prefix
      </code>
      
      , hex or base64, compared in constant time
    </td>
    
    <td>
      GitHub (<code>
        X-Hub-Signature-256
      </code>
      
      , <code>
        sha256=
      </code>
      
      ), Gitea (<code>
        X-Gitea-Signature
      </code>
      
      , no prefix)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        hmac-sha256
      </code>
      
       with <code>
        message
      </code>
      
       and <code>
        timestamp
      </code>
    </td>
    
    <td>
      An HMAC-SHA256 of a timestamped string, refused outside its window
    </td>
    
    <td>
      Slack (<code>
        v0:{timestamp}:{body}
      </code>
      
       in <code>
        X-Slack-Signature
      </code>
      
      , <code>
        v0=
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ed25519
      </code>
    </td>
    
    <td>
      An Ed25519 signature of <code>
        message
      </code>
      
      , checked with the public key in the secret (hex or base64)
    </td>
    
    <td>
      Discord (<code>
        {timestamp}{body}
      </code>
      
       in <code>
        X-Signature-Ed25519
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        jwt
      </code>
    </td>
    
    <td>
      A JSON Web Token in <code>
        header
      </code>
      
       after <code>
        prefix
      </code>
      
      , signed with a key the sender publishes, from one of its issuers, for the audience in the secret, <a href="https://docs.oz0.ai/build/parts/event-sources#bearer-tokens-jwt">
        below
      </a>
    </td>
    
    <td>
      Microsoft's Bot Framework (Teams)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token
      </code>
    </td>
    
    <td>
      The secret itself in <code>
        header
      </code>
      
      , after <code>
        prefix
      </code>
    </td>
    
    <td>
      GitLab (<code>
        X-Gitlab-Token
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        plugin
      </code>
    </td>
    
    <td>
      Nothing: your function checks it, with the secret in its <code>
        requires.secrets
      </code>
    </td>
    
    <td>
      A sender with a scheme the edge cannot express
    </td>
  </tr>
</tbody>
</table>

Prefer `hmac-sha256` or `token`: with `plugin`, every delivery, forged or not, reaches a node before it is checked, and the secret reaches your plugin's processes. `plugin lint` warns about it.

### Bearer tokens (jwt)

Some senders sign a delivery with a token rather than an HMAC of its body: a JSON Web Token (RFC 7519) in a header,
signed with a key they publish as a JSON Web Key Set, often behind OpenID metadata. The edge checks it before
anything reads the delivery:

```yaml [oz0-plugin.yaml]
events:
  - name: teams
    call: oz0_teams.normalize
    secret: TEAMS_APP_ID              # the audience the tokens must be for, such as a bot's app ID
    signature:
      scheme: jwt
      header: Authorization
      prefix: "Bearer "
      openid_url: https://login.botframework.com/v1/.well-known/openidconfiguration   # or jwks_url: the key set itself
      issuers: [https://api.botframework.com]
      algorithms: [RS256]             # the default; RS, PS and ES of 256, 384 and 512 bits may be listed
      max_age_s: 300                  # the clock skew allowed on exp and nbf
      claims: [serviceurl]            # claims your function gets as verified facts
      key_fields: [endorsements]      # members of the signing key it gets, as key.<member>
```

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        openid_url
      </code>
      
       or <code>
        jwks_url
      </code>
    </td>
    
    <td>
      Where the keys are: OpenID metadata whose <code>
        jwks_uri
      </code>
      
       lists them, or the key set. HTTPS only.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        key_hosts
      </code>
    </td>
    
    <td>
      More hosts the keys may come from, when the metadata's <code>
        jwks_uri
      </code>
      
       is on another host (<code>
        *.example.com
      </code>
      
       for any name under it that the domain's owner holds; a wildcard over a public suffix such as <code>
        *.github.io
      </code>
      
       is refused). The edge fetches keys from no other host.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        issuers
      </code>
    </td>
    
    <td>
      The token's <code>
        iss
      </code>
      
       must be one of them.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        algorithms
      </code>
    </td>
    
    <td>
      The token's <code>
        alg
      </code>
      
       must be one of them: never <code>
        none
      </code>
      
       and never an HMAC, whose secret would be the public key.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        claims
      </code>
      
      , <code>
        key_fields
      </code>
    </td>
    
    <td>
      What reaches your function in the delivery's <code>
        verified
      </code>
      
       object, checked: claims by name, the key's members as <code>
        key.&lt;member>
      </code>
      
      .
    </td>
  </tr>
</tbody>
</table>

The token must be signed by a key of the set its `kid` names, with a key type that fits its algorithm; its `aud` must be the secret's value (compared in constant time), and its `exp` (required) and `nbf` must hold within `max_age_s`. The edge keeps a key set for the lifetime its `Cache-Control` gives, between five minutes and a day, and fetches it again for a `kid` it does not know at most once a minute, so a flood of tokens with made-up keys costs the sender, not your edge.

Two sources share a kept set only when they name the same URL, read it the same way and allow the same key hosts, and a fetch that finds no key keeps the keys the set had, so another tenant's source cannot change yours. Keys come only over HTTPS with the certificate checked, from public addresses (private ones only in dev mode or on networks `--connect-allow-net` lists), and each fetch is a row on [Outbound connections](https://docs.oz0.ai/concepts/outbound-connections). A token that does not pass gets `401` and is audited as `event.refused` once a minute; why it failed is logged without the token.

Your function gets what the edge verified in `delivery["verified"]`, and should check the body against it: Teams'
plugin refuses an activity whose `serviceUrl` is not the token's `serviceurl` claim, or whose channel the key's
`endorsements` do not list.

Set the secret, and point the sender at the edge:

```bash [Terminal]
oz0 secret set GITHUB_WEBHOOK_SECRET --from-file ~/.secrets/github-webhook --signing --host edge.example.com:7443
oz0 event sources
```

```text
SOURCE  PLUGIN         SIGNATURE    SECRET                 PATH                    TYPES
github  github-events  hmac-sha256  GITHUB_WEBHOOK_SECRET  /events/default/github  pull_request, pull_request_review, check_suite, workflow_run, issue_comment
```

The edge takes a source's secret only as a signing key bound, by name, to one of its own addresses: the edge API's advertised address, its `--public-edge-url`, or a `--san` name at the edge API's port, which the operator sets, never the host a request names. A secret a connection of the same plugin saved (a GitHub App's webhook secret, a Discord public key pasted in Connect) is taken as it is, unless that connection sends it as a credential: its token, the key its tokens are signed with, its client's secret or its refresh token.

Any other secret, a credential such as a database's password, a key that signs what goes to another host or one bound by a wildcard, checks nothing, and every delivery gets the `401` a wrong signature gets, so whoever may name a source's secret cannot name another one and test guesses of it. The edge's log says why. Behind a proxy or load balancer, bind the key to the name senders use and give it to the edges as `--public-edge-url`.

In GitHub, add a webhook with the payload URL `https://<edge>:7443/events/<tenant>/github-events`, content type `application/json` and the same secret.

## Write the normalising function

The function gets one argument, `delivery`:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      What
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        source
      </code>
      
      , <code>
        id
      </code>
    </td>
    
    <td>
      The source's name, and the delivery's ID
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        headers
      </code>
    </td>
    
    <td>
      The request's headers, names in lowercase, without credentials and the signature (with scheme <code>
        plugin
      </code>
      
      , with them)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        content_type
      </code>
    </td>
    
    <td>
      Such as <code>
        application/json
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        body
      </code>
    </td>
    
    <td>
      The body as JSON; for a form (<code>
        application/x-www-form-urlencoded
      </code>
      
      ) its fields, the first value of each; else <code>
        null
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        text
      </code>
    </td>
    
    <td>
      The body as text, when it is not JSON
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        verified
      </code>
    </td>
    
    <td>
      The facts the edge checked, such as a token's claims (scheme <code>
        jwt
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        seal
      </code>
    </td>
    
    <td>
      The edge's <a href="https://docs.oz0.ai/build/parts/event-sources/delivery-binding#carry-a-click-through-a-form">
        seal
      </a>
      
       of this delivery's facts, when the source declares one and its <code>
        when
      </code>
      
       holds: carry it where the other system sends it back
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        sealed
      </code>
      
      , <code>
        seal_refused
      </code>
    </td>
    
    <td>
      The facts of the seal this delivery carried back, which the edge opened, or why the one it carried did not open
    </td>
  </tr>
</tbody>
</table>

It answers with structured content or JSON text:

```json
{"events": [{"type": "check_suite", "action": "completed", "subject": "octo/demo#12",
             "attributes": {"conclusion": "success", "head_sha": "abc123"},
             "text": "check_suite github-actions on abc123: completed, success"}]}
```

- `type` and `action` say what happened in the source's own words: lowercase letters, digits, dots, dashes and underscores.
- `subject` names what the event is about, one line of at most 200 bytes, such as `owner/repo#12` for a pull request. Jobs wait for events by subject, so pick one that a job can know before the event comes.
- `attributes` are small typed fields that filters read, at most 8 KB as JSON.
- `text` is what a model reads of the event, at most 16 KB. It is outside text: whatever an issue's author wrote may be in it.

One delivery may give up to 20 events, as many as its bindings tell apart: a check suite for two pull requests is one event for each, and binds their subjects as a list. A delivery that means nothing answers `{"events": [], "ignore": "a ping"}`, and one your function rejects answers `{"refuse": "why"}`, which the sender gets as `401`. The edge adds `source`, `delivery`, `id` (the delivery's ID, with `.1`, `.2` for the second event on) and `at`; your function cannot set them, so it cannot speak for another source.

The example in `examples/github-events` currently requires repository access; public source downloads are coming soon. It reads pull requests, reviews, check suites, workflow runs and comments without any network or secret:

```python [src/github_events/server.py]
from typing import Any

from mcp.server import MCPServer
from mcp.types import ToolAnnotations

from github_events.normalize import normalize as read_delivery

server = MCPServer("github-events")


@server.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False))
def normalize(delivery: dict[str, Any]) -> dict[str, Any]:
    """Turn one GitHub webhook delivery, whose signature the edge checked, into normalised events."""
    return read_delivery(delivery)
```

A normalising function should read the delivery and nothing else: the edge may call it again for a delivery that is sent again, and its time limit is a minute.

## Related tasks

- [Bind and answer deliveries](https://docs.oz0.ai/build/parts/event-sources/delivery-binding): Bind event identities, carry forms and choose delivery responses.
- [Use platform events](https://docs.oz0.ai/build/parts/event-sources/platform-events): Connect chat channels, inventory changes and granted plugin results.

<legacy-sections>

- <span id="answer-at-once">

Section

</span>

: [answer at once](https://docs.oz0.ai/build/parts/event-sources/delivery-binding#answer-at-once)
- <span id="bind-events-to-the-delivery">

Section

</span>

: [bind events to the delivery](https://docs.oz0.ai/build/parts/event-sources/delivery-binding#bind-events-to-the-delivery)
- <span id="carry-a-click-through-a-form">

Section

</span>

: [carry a click through a form](https://docs.oz0.ai/build/parts/event-sources/delivery-binding#carry-a-click-through-a-form)
- <span id="chat-channels">

Section

</span>

: [chat channels](https://docs.oz0.ai/build/parts/event-sources/platform-events#chat-channels)
- <span id="how-deliveries-are-answered">

Section

</span>

: [how deliveries are answered](https://docs.oz0.ai/build/parts/event-sources/delivery-binding#how-deliveries-are-answered)
- <span id="the-inventorys-changes">

Section

</span>

: [the inventorys changes](https://docs.oz0.ai/build/parts/event-sources/platform-events#the-inventorys-changes)
- <span id="the-results-of-granted-plugins">

Section

</span>

: [the results of granted plugins](https://docs.oz0.ai/build/parts/event-sources/platform-events#the-results-of-granted-plugins)

</legacy-sections>


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
