Skip to Content
Webhooks and world events

Guides

Webhooks and world events

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

SimulatorDelivers to your receiverGET /_rystic/egress holdsWorld action
Stripeyes, to endpoints registered with POST /v1/webhook_endpointswebhook eventsPOST /_dispute
Resendyes, to webhooks registered with POST /webhookswebhook events and SMTP handoffsPOST /_bounce
Clerkyes, to one URL set at bootwebhook eventsnone
Slackyes, Events API to a request URL set at boot, or over Socket ModeEvents API eventsnone
GitHubnowebhook eventsnone
PayPalnowebhook eventsnone
Kalshino; its WebSocket feed pushes frames insteadWebSocket framesPOST /_settle
Oktanoactivation emailsnone
LobnonothingPOST /_tracking_event
Linear, Notion, S3, Dropbox, Google Drivenonothingnone

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.

SimulatorSignature headersVerify with
StripeStripe-Signaturestripe.Webhook.construct_event
Resendsvix-id, svix-timestamp, svix-signaturesvix.webhooks.Webhook(secret).verify
Clerksvix-id, svix-timestamp, svix-signaturesvix.webhooks.Webhook(secret).verify
SlackX-Slack-Request-Timestamp, X-Slack-Signatureslack_sdk.signature.SignatureVerifier

Run a receiver

A receiver for Stripe and Resend, in Python:

python3 -m venv .venv && . .venv/bin/activate
pip install stripe svix
# 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:

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:

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:

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 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:
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.

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 has the route reference.

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.

SimulatorCallWhat changesEvent
StripePOST /_dispute {"charge":"ch_…","reason"?,"amount"?}the charge reads disputed: true; a refund answers 400 charge_disputedcharge.dispute.created, carrying a needs_response Dispute
ResendPOST /_bounce {"email_id":"…","subType"?,"message"?,"diagnosticCode"?}an email that has gone out reads last_event: "bounced" with its bounce (Resend → World actions)email.bounced
LobPOST /_tracking_event {"postcard":"psc_…","name":"In Transit","location"?,"time"?}a USPS scan joins the postcard’s tracking_eventsnone: Lob sends no webhooks
KalshiPOST /_settle {"ticker":"…","result":"yes"}the market settles (Kalshi → 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, and bounce an email to an ordinary address:

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 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}:

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:

kill $RECEIVER
rystic stop stripe-hooks resend-hooks
Last updated on