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
| Simulator | Delivers to your receiver | GET /_rystic/egress holds | World action |
|---|---|---|---|
| Stripe | yes, to endpoints registered with POST /v1/webhook_endpoints | webhook events | POST /_dispute |
| Resend | yes, to webhooks registered with POST /webhooks | webhook events and SMTP handoffs | POST /_bounce |
| Clerk | yes, to one URL set at boot | webhook events | none |
| Slack | yes, Events API to a request URL set at boot, or over Socket Mode | Events API events | none |
| GitHub | no | webhook events | none |
| PayPal | no | webhook events | none |
| Kalshi | no; its WebSocket feed pushes frames instead | WebSocket frames | POST /_settle |
| Okta | no | activation emails | none |
| Lob | no | nothing | POST /_tracking_event |
| Linear, Notion, S3, Dropbox, Google Drive | no | nothing | none |
Receive signed webhooks
- Stripe and Resend deliver only under
RYSTIC_WEBHOOK_DELIVERY=on(rystic run … --env RYSTIC_WEBHOOK_DELIVERY=on). Unset oroff, nothing is POSTed; any other value stops the simulator before it serves. - Each event goes to every enabled endpoint whose event list names it, signed with that endpoint’s own secret. A disabled endpoint receives nothing.
- Clerk and Slack take one receiver URL and secret at boot:
CLERK_TWIN_WEBHOOK_URLwith awhsec_CLERK_TWIN_WEBHOOK_SECRET, andSLACK_TWIN_EVENTS_URLwithSLACK_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 | Stripe-Signature | stripe.Webhook.construct_event |
| Resend | svix-id, svix-timestamp, svix-signature | svix.webhooks.Webhook(secret).verify |
| Clerk | svix-id, svix-timestamp, svix-signature | svix.webhooks.Webhook(secret).verify |
| Slack | X-Slack-Request-Timestamp, X-Slack-Signature | slack_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
- 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.
- 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.
- Recorded on the egress log. Under delivery,
GET /_rystic/egressadds a top-leveldeliveryobject, besideevents, 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}
seqis the event’s egressseq;endpointis the registered endpoint’s id (Clerk and Slack, with one URL, leave it out).statusisdeliveredfor a 2xx answer andfailedotherwise; a failed row has nohttp_statuswhen the receiver could not be reached.pendingcounts attempts still queued andin_flightistruewhile one is being sent. Wait for both to clear before asserting onresults.- Without delivery on, the response has no
deliverykey.
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.
| 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) | 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) | market_lifecycle_v2 frames |
- Stripe answers
404for an unknown charge and409charge_already_disputedfor one already disputed. - Resend answers
422withoutemail_id,404for an unknown email and409for one already bounced. - Lob answers
422for 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) and404for 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
attakes hours, minutes and seconds (+72h); a day unit such as+3drefuses the schedule.- The clock stays where you put it unless the simulator runs with
RYSTIC_PACE, so a test reaches each event by moving the clock. - A schedule survives
resetand replays from its first event; the endpoints it delivers to do not.
Clean up:
kill $RECEIVER
rystic stop stripe-hooks resend-hooks