Stripe checkout webhook: start a paid job once per payment
Listen for checkout.session.completed, verify the signature on the raw body, answer 2xx fast, then start the job keyed by the Checkout Session id.

To act on a Stripe Checkout payment, listen for the checkout.session.completed webhook event, verify its signature on the raw request body, return a 2xx quickly, and fulfill from there. Stripe may deliver the same event more than once, so the step that starts paid work (here, a generation job) must run once per Checkout Session: key it by the session id.
The Stripe facts come from Receive Stripe events in your webhook endpoint and Fulfill orders. The paid job is a Sume Format run, from Create a run and Embed a Format. All were read on 2026-09-29. Sume has no Stripe connector; this is your server calling two HTTPS APIs.
Which Stripe webhook event means the customer paid?
When someone pays, Stripe creates a checkout.session.completed event. Stripe's fulfillment guide says to check the session's payment_status before fulfilling; its sample fulfills when it is not unpaid. Delayed methods such as ACH direct debit and other bank transfers send checkout.session.async_payment_succeeded when the payment succeeds later, so listen for both.
Don't start the job from your success page. Stripe says it's not guaranteed customers visit that page: a customer can pay and lose their connection before it loads.
How do I set up and verify a Stripe checkout webhook?
In practice: verify, save the session id to a queue or table, answer 200, and start the job from a worker.
- Register a public HTTPS endpoint. For local tests,
stripe listen --forward-to localhost:4242/webhookforwards events and prints a signing secret. - Verify each event with Stripe's library, from the raw body, the
Stripe-Signatureheader and the endpoint'swhsec_secret. Any change to the raw body makes verification fail, so keep your framework from parsing it first. - Stripe's libraries accept a 5-minute gap between the signed timestamp and now by default.
- Return a 2xx quickly, before any complex logic that could cause a timeout. With a
success_urlset, Checkout waits up to 10 seconds for your response before redirecting the customer.
Why does Stripe send the same checkout event twice?
Because delivery is retried and not exactly-once. Each case below reaches your handler again for a session you may already have fulfilled:
| What Stripe does | What your handler must survive |
|---|---|
| Retries undelivered events for up to three days with exponential backoff (live mode) | A late copy of an event you already handled |
| May deliver the same event more than once | A duplicate even after a 2xx |
| May call your fulfillment several times, possibly concurrently, for one Checkout Session | Two copies arriving at the same moment |
Sends checkout.session.async_payment_succeeded later for delayed methods | A second event type for the same session |
How do I start the job only once per payment?
Build the job's Idempotency-Key from the Checkout Session id, so every repeat lands on the first job. Sume's docs say to derive the key from the thing being made, plus a version you bump when you want a re-run on purpose:
SESSION_ID="cs_test_123" # from the verified event's data.object
curl -sS -X POST "https://api.sume.com/v1/formats/acme/product-promo/runs" \
-H "Authorization: Bearer $SUME_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: stripe-$SESSION_ID-v1" \
-d '{
"input": { "product_url": "https://example.com/p/8823" },
"generation_spend_cap_usd": 3,
"communication": { "webhook_url": "https://example.com/hooks/sume" }
}'Why does the request body have to stay the same?
Because Sume replays only the same key with the same body: that returns 200 with the original run and idempotency_hit: true, while a changed body is 409 idempotency_conflict and nothing runs. Two copies at the same moment get one run; the loser sees 409 idempotency_key_in_use, which is retryable. Build the body only from stored order data, never from the time or a per-attempt value, and keep the webhook_url fixed; it is part of the body.
Store the returned run id against the order before you mark it fulfilled. Spend caps, key custody and billing your customer are covered in Embed AI video generation in your product; Stripe metered billing for AI video covers charging per job instead of per checkout.
Sources
Related posts
More in Integrations
- Tenacity retry in Python: safe settings for a paid API POST
Bare @retry in tenacity retries forever with no wait. For a paid POST, cap attempts, add jittered backoff, retry only transient errors, and reuse one key.
- Workato HTTP connector: call an API with a key and JSON
Workato's HTTP connector calls any HTTP API: a Header auth connection holds the key, and Send request via HTTP POSTs a JSON body and maps the reply.
- How to add an MCP server to ChatGPT with developer mode
Turn on ChatGPT developer mode, create an app for the server's URL, and sign in with OAuth. The steps, with Sume's hosted MCP server as the example.
- How to add subtitles to a video in Python
Add subtitles to a video in Python with Requests: POST the video URL to Sume's /v1/video-captions, poll the job, then read the captioned video_url.
Written by Sume