# Webhooks and world events

Test the outbound side of an integration across simulators — signed webhook delivery to your receiver, the egress log, world actions and timelines.

Your code registers webhook endpoints through the vendor's own API, as it does in production. The simulator records every event it would send and, when you turn delivery on, POSTs each one to your receiver, signed the way the vendor signs it. World actions cause the events only the provider can: a dispute, a bounce, a USPS scan.

## Which simulators send what

| Simulator | Delivers to your receiver | `GET /_rystic/egress` holds | World action |
|---|---|---|---|
| [Stripe](https://www.rystic.ai/docs/simulators/stripe.md#egress-log) | yes, to endpoints registered with `POST /v1/webhook_endpoints` | webhook events | `POST /_dispute` |
| [Resend](https://www.rystic.ai/docs/simulators/resend.md#egress-log) | yes, to webhooks registered with `POST /webhooks` | webhook events and SMTP handoffs | `POST /_bounce` |
| [Clerk](https://www.rystic.ai/docs/simulators/clerk.md#egress) | yes, to one URL set at boot | webhook events | none |
| [Slack](https://www.rystic.ai/docs/simulators/slack.md#events-api) | yes, Events API to a request URL set at boot, or over Socket Mode | Events API events | none |
| [GitHub](https://www.rystic.ai/docs/simulators/github-pulls.md#egress-log) | no | webhook events | none |
| [PayPal](https://www.rystic.ai/docs/simulators/paypal.md#egress-log) | no | webhook events | none |
| [Kalshi](https://www.rystic.ai/docs/simulators/kalshi.md#egress-log) | no; its WebSocket feed pushes frames instead | WebSocket frames | `POST /_settle` |
| [Okta](https://www.rystic.ai/docs/simulators/okta.md) | no | activation emails | none |
| [Lob](https://www.rystic.ai/docs/simulators/lob.md) | no | nothing | `POST /_tracking_event` |
| [Linear](https://www.rystic.ai/docs/simulators/linear.md), [Notion](https://www.rystic.ai/docs/simulators/notion.md), [S3](https://www.rystic.ai/docs/simulators/s3.md), Dropbox, Google Drive | no | nothing | none |

## Receive signed webhooks

1. **Stripe and Resend** deliver only under `RYSTIC_WEBHOOK_DELIVERY=on` (`rystic run … --env RYSTIC_WEBHOOK_DELIVERY=on`). Unset or `off`, nothing is POSTed; any other value stops the simulator before it serves.
2. Each event goes to every enabled endpoint whose event list names it, signed with that endpoint's own secret. A disabled endpoint receives nothing.
3. **Clerk and Slack** take one receiver URL and secret at boot: `CLERK_TWIN_WEBHOOK_URL` with a `whsec_` `CLERK_TWIN_WEBHOOK_SECRET`, and `SLACK_TWIN_EVENTS_URL` with `SLACK_TWIN_SIGNING_SECRET`.

Verify each delivery with the vendor's own library. The simulator pages have the signing details.

| Simulator | Signature headers | Verify with |
|---|---|---|
| [Stripe](https://www.rystic.ai/docs/simulators/stripe.md#egress-log) | `Stripe-Signature` | `stripe.Webhook.construct_event` |
| [Resend](https://www.rystic.ai/docs/simulators/resend.md#egress-log) | `svix-id`, `svix-timestamp`, `svix-signature` | `svix.webhooks.Webhook(secret).verify` |
| [Clerk](https://www.rystic.ai/docs/simulators/clerk.md#egress) | `svix-id`, `svix-timestamp`, `svix-signature` | `svix.webhooks.Webhook(secret).verify` |
| [Slack](https://www.rystic.ai/docs/simulators/slack.md#events-api) | `X-Slack-Request-Timestamp`, `X-Slack-Signature` | `slack_sdk.signature.SignatureVerifier` |

### Run a receiver

A receiver for Stripe and Resend, in Python:

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install stripe svix
```

```python
# receiver.py: verifies each delivery with the vendor's own library
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer

import stripe
from svix.webhooks import Webhook

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers["Content-Length"]))
        try:
            if self.path == "/stripe":
                event = stripe.Webhook.construct_event(
                    body, self.headers["Stripe-Signature"], os.environ["STRIPE_WEBHOOK_SECRET"])
            else:  # /resend
                Webhook(os.environ["RESEND_WEBHOOK_SECRET"]).verify(body, dict(self.headers))
                event = json.loads(body)
        except Exception as e:
            print("rejected", self.path, e, flush=True)
            self.send_response(400)
        else:
            print("verified", self.path, event["type"], flush=True)
            self.send_response(200)
        self.end_headers()

    def log_message(self, *args):
        pass

HTTPServer(("127.0.0.1", 4242), Receiver).serve_forever()
```

Start both simulators with delivery on:

```bash
rystic pull stripe-twin && rystic pull resend-twin
STRIPE=$(rystic run stripe-twin -d --name stripe-hooks --port 0 --env RYSTIC_WEBHOOK_DELIVERY=on --json | jq -r .data.port)
RESEND=$(rystic run resend-twin -d --name resend-hooks --port 0 --env RYSTIC_WEBHOOK_DELIVERY=on --json | jq -r .data.port)
```

Register an endpoint on each through the vendor's API, keep the secrets, then start the receiver:

```bash
export STRIPE_WEBHOOK_SECRET=$(curl -s localhost:$STRIPE/v1/webhook_endpoints -u sk_test_twin: \
  -d url=http://127.0.0.1:4242/stripe \
  -d 'enabled_events[]=charge.succeeded' -d 'enabled_events[]=charge.dispute.created' | jq -r .secret)
# -> whsec_000000000000000000000001

export RESEND_WEBHOOK_SECRET=$(curl -s localhost:$RESEND/webhooks -H 'Authorization: Bearer re_test' \
  -H 'Content-Type: application/json' \
  -d '{"endpoint":"http://127.0.0.1:4242/resend","events":["email.sent","email.bounced"]}' | jq -r .signing_secret)
# -> whsec_f886911f8d6c49918000000000000001

python3 receiver.py & RECEIVER=$!
```

Secrets come from the simulator's id sequence, so a fresh simulator hands out the same ones every run. Charge a card on Stripe and send to Resend's [sandbox bounce address](https://www.rystic.ai/docs/simulators/resend.md#sandbox-recipients):

```bash
curl -s localhost:$STRIPE/v1/charges -u sk_test_twin: \
  -d amount=2000 -d currency=usd -d source=tok_visa > /dev/null
curl -s localhost:$RESEND/emails -H 'Authorization: Bearer re_test' -H 'Content-Type: application/json' \
  -d '{"from":"Acme <onboarding@resend.dev>","to":["bounced@resend.dev"],"subject":"Receipt","html":"<p>Thanks</p>"}' > /dev/null
# receiver: verified /stripe charge.succeeded
#           verified /resend email.sent
#           verified /resend email.bounced
```

### What a delivery does

1. **One attempt, no retries.** Each endpoint gets one POST per event; a non-2xx answer or an unreachable receiver is not retried. This holds on Stripe, Resend, Clerk and Slack's request URL. Slack's Socket Mode redelivers, as [Slack → Events API](https://www.rystic.ai/docs/simulators/slack.md#events-api) describes.
2. **Outside the request.** Deliveries go out in event order after the API has answered; a failed delivery never undoes the write or changes the answer.
3. **Recorded on the egress log.** Under delivery, `GET /_rystic/egress` adds a top-level `delivery` object, beside `events`, with one row per attempt:

```bash
curl -s localhost:$STRIPE/_rystic/egress | jq -c '.delivery.results[]'
# -> {"generation":0,"seq":1,"endpoint":"we_0000000000000001","status":"delivered","http_status":200}
```

1. `seq` is the event's egress `seq`; `endpoint` is the registered endpoint's id (Clerk and Slack, with one URL, leave it out).
2. `status` is `delivered` for a 2xx answer and `failed` otherwise; a failed row has no `http_status` when the receiver could not be reached.
3. `pending` counts attempts still queued and `in_flight` is `true` while one is being sent. Wait for both to clear before asserting on `results`.
4. Without delivery on, the response has no `delivery` key.

`POST /_rystic/reset` wipes the registered endpoints with the rest of the state and clears `delivery`; register again after a reset.

### Test a stale signature

`wall_offset_seconds` on `/_rystic/clock` shifts the timestamp Stripe, Resend and Clerk sign with, so you can check that your receiver refuses an old delivery. A positive offset matches a receiver whose clock you moved forward. `0` or a reset puts it back.

```bash
curl -s -X POST localhost:$STRIPE/_rystic/clock -d '{"wall_offset_seconds":-600}'
curl -s localhost:$STRIPE/v1/charges -u sk_test_twin: \
  -d amount=500 -d currency=usd -d source=tok_visa > /dev/null
# receiver: rejected /stripe Timestamp outside the tolerance zone (…)

curl -s localhost:$STRIPE/_rystic/egress | jq -c '.delivery.results[-1]'
# -> {"generation":0,"seq":2,"endpoint":"we_0000000000000001","status":"failed","http_status":400}

curl -s -X POST localhost:$STRIPE/_rystic/clock -d '{"wall_offset_seconds":0}'
```

## Assert without a receiver: the egress log

`GET /_rystic/egress` lists every event a simulator would send, in order, whether or not delivery is on and whether or not an endpoint is registered. An event's `payload` is the body its delivery carries. [Control plane → Routes by plan](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) has the route reference.

```bash
curl -s localhost:$RESEND/_rystic/egress | jq -r '.events[] | "\(.seq) \(.type)"'
# -> 1 smtp-delivery/bounced@resend.dev
#    2 email.sent
#    3 email.bounced
```

## World actions

A world action is the provider acting on its own: a cardholder's bank, a mail server, the post office, the exchange. Each is a control route on the simulator; send its body as JSON. Its event goes to the egress log and to every endpoint registered for it, like any other.

| Simulator | Call | What changes | Event |
|---|---|---|---|
| Stripe | `POST /_dispute {"charge":"ch_…","reason"?,"amount"?}` | the charge reads `disputed: true`; a refund answers `400` `charge_disputed` | `charge.dispute.created`, carrying a `needs_response` Dispute |
| Resend | `POST /_bounce {"email_id":"…","subType"?,"message"?,"diagnosticCode"?}` | an email that has gone out reads `last_event: "bounced"` with its `bounce` ([Resend → World actions](https://www.rystic.ai/docs/simulators/resend.md#world-actions)) | `email.bounced` |
| Lob | `POST /_tracking_event {"postcard":"psc_…","name":"In Transit","location"?,"time"?}` | a USPS scan joins the postcard's `tracking_events` | none: Lob sends no webhooks |
| Kalshi | `POST /_settle {"ticker":"…","result":"yes"}` | the market settles ([Kalshi → Settling a market](https://www.rystic.ai/docs/simulators/kalshi.md#settling-a-market)) | `market_lifecycle_v2` frames |

1. Stripe answers `404` for an unknown charge and `409` `charge_already_disputed` for one already disputed.
2. Resend answers `422` without `email_id`, `404` for an unknown email and `409` for one already bounced.
3. Lob answers `422` for a name outside Lob's tracking event names (`Mailed`, `In Transit`, `In Local Area`, `Processed for Delivery`, `Delivered`, `Re-Routed`, `Returned to Sender`, `International Exit`) and `404` for an unknown postcard.

Dispute the charge from [Run a receiver](#run-a-receiver), and bounce an email to an ordinary address:

```bash
curl -s -X POST localhost:$STRIPE/_dispute -H 'Content-Type: application/json' \
  -d '{"charge":"ch_000000000000000000000001","reason":"product_not_received"}' | jq -c '{id, status, reason}'
# -> {"id":"dp_0000000000000001","status":"needs_response","reason":"product_not_received"}
# receiver: verified /stripe charge.dispute.created

ID=$(curl -s localhost:$RESEND/emails -H 'Authorization: Bearer re_test' -H 'Content-Type: application/json' \
  -d '{"from":"Acme <onboarding@resend.dev>","to":["ada@example.com"],"subject":"Receipt","html":"<p>Thanks</p>"}' | jq -r .id)
curl -s -X POST localhost:$RESEND/_bounce -H 'Content-Type: application/json' \
  -d "{\"email_id\":\"$ID\",\"subType\":\"NoEmail\"}" | jq -c '{last_event, subType: .bounce.subType}'
# -> {"last_event":"bounced","subType":"NoEmail"}
# receiver: verified /resend email.sent
#           verified /resend email.bounced
```

## Schedule events on a timeline

A timeline fires calls on the simulator's clock, world actions included; [Control plane → Timelines](https://www.rystic.ai/docs/control-plane.md#timelines) has the mechanism. A world action is a call to its route with no `as`. Charge a card a minute in and dispute it three days later, binding the charge id as `${ch}`:

```bash
curl -s -X POST localhost:$STRIPE/_rystic/inject -H 'Content-Type: application/json' -d '{
  "events": [
    {"id": "charge", "at": "+1m",
     "call": {"method": "POST", "path": "/v1/charges",
              "body": {"amount": 4200, "currency": "usd", "source": "tok_visa"}},
     "bind": {"ch": "/id"}},
    {"id": "dispute", "at": "+72h",
     "call": {"method": "POST", "path": "/_dispute", "body": {"charge": "${ch}"}}}
  ]
}'
# answers 204

curl -s -X POST localhost:$STRIPE/_rystic/clock -d '{"advance_seconds":259200}'
# receiver: verified /stripe charge.succeeded
#           verified /stripe charge.dispute.created
```

1. `at` takes hours, minutes and seconds (`+72h`); a day unit such as `+3d` refuses the schedule.
2. The clock stays where you put it unless the simulator runs with `RYSTIC_PACE`, so a test reaches each event by moving the clock.
3. A schedule survives `reset` and replays from its first event; the endpoints it delivers to do not.

Clean up:

```bash
kill $RECEIVER
rystic stop stripe-hooks resend-hooks
```

---

Source: https://www.rystic.ai/docs/webhooks · Markdown: https://www.rystic.ai/docs/webhooks.md
