Stripe and GitHub webhooks
Start a run for each Stripe event and each GitHub pull request, with every delivery's signature checked before anything runs.
What you'll build
Two workflows and two signed triggers. stripe-events runs for each refunded charge Stripe tells you about, and github-prs for each pull request opened in a repository. A delivery with a bad signature starts nothing, and a sender's retry of a delivery starts no second run.
- Write and deploy the workflows
Each takes the sender's JSON as its payload.
- Create a trigger for each
Stripe gives you its signing secret after you add the URL; for GitHub, you choose the secret.
- Point the sender at the trigger
Then send a test delivery and find its run.
Write the workflows
A trigger passes the request body to the run as payload, parsed when it's sent as application/json, as Stripe and GitHub send it. The run gets no headers, so tell events apart by the body: Stripe's type, or GitHub's action and the object the body holds.
import { workflow } from "shiftlabs";
type StripeEvent = {
id: string;
type: string;
data: { object: { id: string; amount_refunded?: number } };
};
export default workflow<StripeEvent>(async ({ payload, step, log }) => {
if (payload.type !== "charge.refunded") return "ignored";
const charge = payload.data.object;
await step.run("record refund", async () => {
log.info("refund", { charge: charge.id, cents: charge.amount_refunded });
});
return charge.id;
});
from shiftlabs import workflow
@workflow
async def stripe_events(ctx):
event = ctx.payload
if event["type"] != "charge.refunded":
return "ignored"
charge = event["data"]["object"]
async def record():
ctx.log.info(
"refund",
{"charge": charge["id"], "cents": charge.get("amount_refunded")},
)
await ctx.step.run("record refund", record)
return charge["id"]
import { workflow } from "shiftlabs";
type PullRequestEvent = {
action?: string;
pull_request?: { number: number; title: string; html_url: string };
repository?: { full_name: string };
};
export default workflow<PullRequestEvent>(async ({ payload, step, log }) => {
const pr = payload.pull_request;
// The ping GitHub sends when you add the webhook has no pull_request.
if (!pr || payload.action !== "opened") return "ignored";
await step.run("note", async () => {
log.info("opened", { repo: payload.repository?.full_name, pr: pr.number });
});
return pr.html_url;
});
from shiftlabs import workflow
@workflow
async def github_prs(ctx):
pr = ctx.payload.get("pull_request")
# The ping GitHub sends when you add the webhook has no pull_request.
if not pr or ctx.payload.get("action") != "opened":
return "ignored"
repo = ctx.payload.get("repository", {}).get("full_name")
async def note():
ctx.log.info("opened", {"repo": repo, "pr": pr["number"]})
await ctx.step.run("note", note)
return pr["html_url"]
curl -X PUT https://api.shiftlabs.org/v1/workflows/stripe-events \
-H "authorization: Bearer $SHIFTLABS_API_KEY" \
--data-binary @stripe-events.ts
curl -X PUT https://api.shiftlabs.org/v1/workflows/github-prs \
-H "authorization: Bearer $SHIFTLABS_API_KEY" \
--data-binary @github-prs.ts
# Python: the same, with -H "content-type: text/x-python" and the .py file.
Stripe
Stripe shows an endpoint's signing secret only once the endpoint exists, so create the trigger first, add its URL to Stripe, then turn signing on. The URL stays the same.
- Create the trigger
POST /v1/triggerswith{"workflow": "stripe-events"}answers{triggerId, url}. Until step 3, anyone with the URL can start a run, so keep it to yourself. - Add the endpoint in Stripe
Add the trigger's URL as a webhook endpoint, and choose the events to send, such as
charge.refunded. Stripe then shows the endpoint's signing secret,whsec_…. - Turn on signing
PATCH /v1/triggers/:idwith thestripescheme and that secret. From now on a delivery needs a validStripe-Signature.
# Answers 201 {"triggerId", "url", "signingScheme": null}.
curl -X POST https://api.shiftlabs.org/v1/triggers \
-H "authorization: Bearer $SHIFTLABS_API_KEY" \
-H "content-type: application/json" \
-d '{"workflow": "stripe-events"}'
# After adding the URL in Stripe. Answers {"triggerId", "url",
# "signingScheme": "stripe"}.
curl -X PATCH https://api.shiftlabs.org/v1/triggers/<triggerId> \
-H "authorization: Bearer $SHIFTLABS_API_KEY" \
-H "content-type: application/json" \
-d '{"signing": {"scheme": "stripe", "secret": "whsec_…"}}'
- The trigger checks
Stripe-Signatureagainst the secret and refuses a timestamp more than 5 minutes off. A delivery that fails gets401 {"error": "invalid signature"}and starts nothing. - Stripe signs each retry anew, so the trigger knows a retry by the event's
id: the sameidwithin 7 days answers200 {"duplicate": true, "runId"}with the first delivery's run. - A body over 256 KiB gets
413. See Deploy and triggers for every answer a trigger gives.
GitHub
You choose a GitHub webhook's secret, so create the trigger signed from the start.
- Make a secret
Any string of 8 to 256 characters.
openssl rand -hex 32makes one. - Create the trigger
POST /v1/triggerswith thegithubscheme and the secret. - Add the webhook in GitHub
In the repository's Settings → Webhooks, add a webhook with the trigger's URL as the payload URL, content type
application/json, and the same secret. Choose to select individual events, and pick Pull requests.
SECRET=$(openssl rand -hex 32)
# Answers 201 {"triggerId", "url", "signingScheme": "github"}.
curl -X POST https://api.shiftlabs.org/v1/triggers \
-H "authorization: Bearer $SHIFTLABS_API_KEY" \
-H "content-type: application/json" \
-d "{\"workflow\": \"github-prs\",
\"signing\": {\"scheme\": \"github\", \"secret\": \"$SECRET\"}}"
- The trigger checks
X-Hub-Signature-256and needsX-GitHub-Delivery. A delivery without them, or with a signature that doesn't match, gets401 {"error": "invalid signature"}. - The same
X-GitHub-Deliverywithin 7 days answers200 {"duplicate": true, "runId"}and starts no second run. - With content type
application/x-www-form-urlencoded, the payload is the raw form text instead of JSON. Chooseapplication/json. - Each webhook sends the events you picked to one trigger, so one workflow. For another kind of event, add a second webhook with its own trigger and workflow.
Check it worked
- Send a test delivery
With the endpoint added in Stripe's test mode, run
stripe trigger charge.refundedwith the Stripe CLI. In GitHub, open a pull request. - Read the sender's log
Stripe's endpoint page and GitHub's Recent Deliveries show each delivery's answer:
202 {"runId"}for a new run,200 {"duplicate": true, "runId"}for a repeat, and401when the secret on the two sides differs. - Find the run
GET /v1/runs?workflow=stripe-eventslists the runs, newest first, withorigintrigger.GET /v1/runs/:id/timelineshows each step.
curl "https://api.shiftlabs.org/v1/runs?workflow=stripe-events&limit=5" \
-H "authorization: Bearer $SHIFTLABS_API_KEY"
curl https://api.shiftlabs.org/v1/runs/<runId>/timeline \
-H "authorization: Bearer $SHIFTLABS_API_KEY"
Reference
- Deploy and triggers
Signing schemes, duplicates, and every answer a trigger gives.
- Workflows
The run context, durable steps and retries.
- Payload schemas
Refuse a delivery whose body isn't the shape you expect, before it starts a run.
- Limits
Triggers per workspace, and the deliveries a trigger takes in 10 seconds.