examples/storefront Public
Mark orders paid from Stripe's webhook in a Worker of its own
storefront-stripe-webhook verifies the signature and settles the order through a new OrdersService entrypoint on storefront-api, so the API stays the only Worker that writes to D1. markPaid and cancel only move an order forward from pending_payment, which makes Stripe's repeated deliveries harmless.
Changed files
14 files changed292 additions10 deletionsagainst 582fe0a
- Changed.rebasely/architecture/flows/checkout.yaml+5−1
- Changed.rebasely/architecture/system.yaml+15−3
- Changed.rebasely/architecture/views/checkout.yaml+1−1
- Changed.rebasely/kb/concepts/checkout.md+4−4
- Added.rebasely/kb/concepts/payment-webhook.md+38−0
- Added.rebasely/kb/decisions/0006-one-writer-for-the-database.md+22−0
- Changed.rebasely/kb/index.md+2−0
- ChangedREADME.md+7−1
- Changedsrc/index.ts+15−0
- Changedsrc/orders.ts+34−0
- Addedtest/stripe-webhook.test.ts+77−0
- Addedworkers/stripe-webhook/src/index.ts+7−0
- Addedworkers/stripe-webhook/src/webhook.ts+54−0
- Addedworkers/stripe-webhook/wrangler.jsonc+11−0
Changed.rebasely/architecture/flows/checkout.yaml+5−1
| @@ -2,7 +2,8 @@ | ||
| description: >- | ||
| The shopper checks out an open cart. storefront-api takes the stock and creates the order in one | ||
| D1 batch, then asks Stripe for a PaymentIntent and hands its client secret to the browser, which | ||
| - confirms the payment with the Payment Element. | ||
| + confirms the payment with the Payment Element. Stripe reports the result to stripe-webhook, which | ||
| + marks the order paid through OrdersService. | ||
| actors: [shopper] | ||
| steps: | ||
| - { from: shopper, to: storefront-api, label: "POST /carts/:id/checkout, email and address" } | ||
| @@ -12,4 +13,7 @@ | ||
| - { from: storefront-api, to: db, label: save the PaymentIntent id on the order } | ||
| - { from: storefront-api, to: shopper, label: the order and the client secret } | ||
| - { from: shopper, to: stripe, label: confirm the payment with the Payment Element } | ||
| + - { from: stripe, to: stripe-webhook, label: "payment_intent.succeeded, signed" } | ||
| + - { from: stripe-webhook, to: storefront-api, label: "OrdersService.markPaid(order, intent)" } | ||
| + - { from: storefront-api, to: db, label: mark the order paid if it is still pending } | ||
| - { from: shopper, to: storefront-api, label: "GET /orders/:id until it is paid" } | ||
Changed.rebasely/architecture/system.yaml+15−3
| @@ -2,13 +2,24 @@ | ||
| storefront-api: | ||
| kind: worker | ||
| description: >- | ||
| - The public HTTP API (wrangler.jsonc, Worker storefront-api). Shoppers browse the catalog and | ||
| - fill carts; the merchant edits products and uploads their images with the admin token. | ||
| + The public HTTP API (wrangler.jsonc, Worker storefront-api). Shoppers browse the catalog, fill | ||
| + carts and check out; the merchant edits products and uploads their images with the admin | ||
| + token. Its OrdersService entrypoint is how the other Workers change orders: it is the only | ||
| + Worker with the database binding. | ||
| owns: [src/**] | ||
| talks_to: | ||
| - { to: db, via: d1 } | ||
| - { to: images, via: r2 } | ||
| - { to: stripe, via: https } | ||
| + stripe-webhook: | ||
| + kind: worker | ||
| + description: >- | ||
| + Worker storefront-stripe-webhook (workers/stripe-webhook). Verifies Stripe's webhook | ||
| + signatures and settles the order of each PaymentIntent through OrdersService. | ||
| + owns: [workers/stripe-webhook/**] | ||
| + talks_to: | ||
| + - { to: storefront-api, via: rpc } | ||
| + - { to: stripe, via: webhooks } | ||
| db: | ||
| kind: d1 | ||
| description: >- | ||
| @@ -26,5 +37,6 @@ | ||
| kind: external | ||
| description: >- | ||
| Stripe, through the stripe npm package. storefront-api creates a PaymentIntent per order with | ||
| - the secret key; the shopper confirms it in the browser with the Payment Element. | ||
| + the secret key; the shopper confirms it in the browser with the Payment Element, and Stripe | ||
| + reports the outcome to stripe-webhook. | ||
| package: stripe | ||
Changed.rebasely/architecture/views/checkout.yaml+1−1
| @@ -1,2 +1,2 @@ | ||
| description: Everything a checkout touches, from the cart to the charge. | ||
| -components: [storefront-api, db, stripe] | ||
| +components: [storefront-api, stripe-webhook, db, stripe] | ||
Changed.rebasely/kb/concepts/checkout.md+4−4
| @@ -8,7 +8,7 @@ | ||
| - src/orders.ts | ||
| - src/payments.ts | ||
| - migrations/0003_orders.sql | ||
| -reviewed: 2026-10-02 | ||
| +reviewed: 2026-10-05 | ||
| --- | ||
| # Checkout | ||
| @@ -35,8 +35,8 @@ | ||
| | Status | Meaning | | ||
| | --- | --- | | ||
| | `pending_payment` | Created at checkout; stock is held for it | | ||
| -| `paid` | Stripe reported the payment succeeded | | ||
| -| `cancelled` | Not paid; the stock went back | | ||
| +| `paid` | Stripe reported the payment succeeded ([[payment-webhook]]) | | ||
| +| `cancelled` | Not paid; the stock went back in the same batch | | ||
| | `fulfilled` | Handed to the warehouse | | ||
| -`GET /orders/:id` returns the order. The id is a random UUID that only the shopper who checked out has. | ||
| +State changes after checkout are in `src/orders.ts` (`markPaid`, `cancelOrder`) and only move forward from `pending_payment`, so repeating one is harmless. `GET /orders/:id` returns the order. The id is a random UUID that only the shopper who checked out has. | ||
Added.rebasely/kb/concepts/payment-webhook.md+38−0
| @@ -0,0 +1,38 @@ | ||
| +--- | ||
| +id: payment-webhook | ||
| +type: concept | ||
| +owner: justin | ||
| +component: stripe-webhook | ||
| +describes: | ||
| + - workers/stripe-webhook/src/** | ||
| +reviewed: 2026-10-05 | ||
| +--- | ||
| +# The payment webhook | ||
| + | ||
| +An order is paid when Stripe says so, never when the browser says so. Stripe sends events to `https://<stripe-webhook host>/stripe/webhook`, and the `storefront-stripe-webhook` Worker turns them into order changes. | ||
| + | ||
| +## What it does | ||
| + | ||
| +1. Verifies the `stripe-signature` header against `STRIPE_WEBHOOK_SECRET` with Web Crypto. A bad or missing signature gets a 400 and changes nothing. | ||
| +2. For `payment_intent.succeeded`, calls `OrdersService.markPaid(order, intent)` on storefront-api. The order id comes from the intent's metadata, which checkout set ([[checkout]]). | ||
| +3. For `payment_intent.canceled`, calls `OrdersService.cancel(order)`, which puts the stock back. | ||
| +4. Answers 200 to everything else, including events for intents without an `order_id`, so Stripe stops retrying them. | ||
| + | ||
| +The Worker has no database binding. It reaches orders only through the service binding `ORDERS` ([[0006-one-writer-for-the-database]]). | ||
| + | ||
| +## Retries and repeats | ||
| + | ||
| +Stripe delivers each event at least once and retries anything that does not get a 2xx. Both calls are safe to repeat: | ||
| + | ||
| +- `markPaid` only moves an order from `pending_payment` to `paid`, and only when the intent id matches the one stored at checkout. | ||
| +- `cancel` puts stock back in the same batch that cancels the order, and only while the order is pending. | ||
| + | ||
| +If OrdersService throws, the webhook answers 500 and Stripe tries again later. | ||
| + | ||
| +## A payment for a cancelled order | ||
| + | ||
| +If an order was cancelled and its payment still succeeds, `markPaid` leaves the order cancelled and the Worker logs `payment for a cancelled order, refund it` as an error. That should not happen once unpaid orders cancel their intent first, but the log line is there to search for. | ||
| + | ||
| +## Local testing | ||
| + | ||
| +`stripe listen --forward-to localhost:8788/stripe/webhook` prints a webhook secret for `.dev.vars` and forwards test-mode events. Run the Worker with `npx wrangler dev -c workers/stripe-webhook/wrangler.jsonc --port 8788` next to storefront-api. | ||
Added.rebasely/kb/decisions/0006-one-writer-for-the-database.md+22−0
| @@ -0,0 +1,22 @@ | ||
| +--- | ||
| +id: 0006-one-writer-for-the-database | ||
| +type: decision | ||
| +owner: justin | ||
| +status: accepted | ||
| +reviewed: 2026-10-05 | ||
| +--- | ||
| +# 0006 storefront-api is the only Worker that writes to D1 | ||
| + | ||
| +## Context | ||
| + | ||
| +Payment results arrive at a separate Worker ([[payment-webhook]]) so its URL, secret and retries stay apart from the public API. That Worker has to change orders. It could bind the database and run its own SQL, or ask storefront-api to do it. | ||
| + | ||
| +## Decision | ||
| + | ||
| +Only storefront-api has the `DB` binding. Other Workers change orders through its `OrdersService` entrypoint over a service binding (`ORDERS`), with calls that name the change: `markPaid`, `cancel`. The SQL for an order's states lives in one file, `src/orders.ts`. | ||
| + | ||
| +## Consequences | ||
| + | ||
| +- The rules for moving an order between states, and for giving stock back, are written once and tested once. | ||
| +- A schema change only has to be deployed with one Worker. | ||
| +- Service binding calls stay inside Cloudflare and add no network hop worth measuring, but they couple deploys: OrdersService methods are an interface, so add new ones before callers use them and remove old ones only after. | ||
Changed.rebasely/kb/index.md+2−0
| @@ -8,6 +8,7 @@ | ||
| - [[carts]]: what a cart holds and the limits it enforces. | ||
| - [[product-images]]: how images get into R2 and back out. | ||
| - [[checkout]]: how a cart becomes an order, and how stock and payment are handled. | ||
| +- [[payment-webhook]]: how Stripe's events mark orders paid or cancelled. | ||
| ## Decisions | ||
| @@ -16,6 +17,7 @@ | ||
| - [[0003-money-in-whole-cents]]: prices and totals are integers. | ||
| - [[0004-take-stock-at-checkout]]: carts check stock, checkout takes it. | ||
| - [[0005-stripe-payment-intents]]: payments are Stripe PaymentIntents. | ||
| +- [[0006-one-writer-for-the-database]]: other Workers change orders through OrdersService. | ||
| ## Guides | ||
ChangedREADME.md+7−1
| @@ -2,6 +2,11 @@ | ||
| The API behind a small online shop, running on Cloudflare Workers. Products, carts and orders live in D1, product images in R2, and payments go through Stripe. | ||
| +There are two Workers: | ||
| + | ||
| +- `storefront-api` (`src/`) is the public API and the only Worker that writes to the database. | ||
| +- `storefront-stripe-webhook` (`workers/stripe-webhook/`) receives Stripe's events and settles orders through storefront-api's `OrdersService` entrypoint. | ||
| + | ||
| It is a plain fetch handler with a small router, no framework, and the tests run the real SQL migrations on Node's built-in SQLite, so `npm test` needs nothing but Node 24. | ||
| ## Running it | ||
| @@ -52,7 +57,8 @@ | ||
| ## Layout | ||
| ``` | ||
| -src/ storefront-api: router, catalog, carts, images, checkout | ||
| +src/ storefront-api: router, catalog, carts, images, checkout, orders | ||
| +workers/ the other Workers, each with its own wrangler.jsonc | ||
| migrations/ D1 schema, applied in order | ||
| test/ node --test suites and the D1 and R2 stand-ins | ||
| .rebasely/ architecture map, knowledge base and CI workflow | ||
Changedsrc/index.ts+15−0
| @@ -1,5 +1,20 @@ | ||
| +import { WorkerEntrypoint } from "cloudflare:workers"; | ||
| import { createApp } from "./app.ts"; | ||
| import type { Env } from "./env.ts"; | ||
| +import { cancelOrder, markPaid, type Settled } from "./orders.ts"; | ||
| + | ||
| +// The calls the shop's other Workers make through a service binding (entrypoint OrdersService). | ||
| +// storefront-api stays the only Worker that writes to the database; see | ||
| +// .rebasely/kb/decisions/0006-one-writer-for-the-database.md. | ||
| +export class OrdersService extends WorkerEntrypoint<Env> { | ||
| + markPaid(orderId: string, paymentIntentId: string): Promise<Settled> { | ||
| + return markPaid(this.env.DB, orderId, paymentIntentId, Date.now()); | ||
| + } | ||
| + | ||
| + cancel(orderId: string): Promise<Settled> { | ||
| + return cancelOrder(this.env.DB, orderId, Date.now()); | ||
| + } | ||
| +} | ||
| export default { | ||
| fetch(req, env) { | ||
Changedsrc/orders.ts+34−0
| @@ -60,3 +60,37 @@ | ||
| created_at: new Date(row.created_at).toISOString(), | ||
| }; | ||
| } | ||
| + | ||
| +// What the payment side learns back: the order's status after the call, and whether the call | ||
| +// changed it. Both calls are idempotent, because Stripe delivers webhooks at least once. | ||
| +export type Settled = { order_id: string; status: OrderStatus; changed: boolean }; | ||
| + | ||
| +export async function markPaid(db: D1Database, orderId: string, paymentIntentId: string, now: number): Promise<Settled> { | ||
| + const r = await db | ||
| + .prepare("UPDATE orders SET status = 'paid', updated_at = ? WHERE id = ? AND payment_intent_id = ? AND status = 'pending_payment'") | ||
| + .bind(now, orderId, paymentIntentId) | ||
| + .run(); | ||
| + return settled(db, orderId, r.meta.changes > 0); | ||
| +} | ||
| + | ||
| +// Cancels an order that is still waiting for payment and puts its stock back, in one batch. The | ||
| +// stock update runs first and only while the order is pending, so a second call changes nothing. | ||
| +export async function cancelOrder(db: D1Database, orderId: string, now: number): Promise<Settled> { | ||
| + const [, cancel] = await db.batch([ | ||
| + db | ||
| + .prepare( | ||
| + `UPDATE products SET stock = stock + (SELECT quantity FROM order_items WHERE order_id = ?1 AND product_id = products.id), updated_at = ?2 | ||
| + WHERE id IN (SELECT product_id FROM order_items WHERE order_id = ?1) | ||
| + AND EXISTS (SELECT 1 FROM orders WHERE id = ?1 AND status = 'pending_payment')`, | ||
| + ) | ||
| + .bind(orderId, now), | ||
| + db.prepare("UPDATE orders SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'pending_payment'").bind(now, orderId), | ||
| + ]); | ||
| + return settled(db, orderId, (cancel?.meta.changes ?? 0) > 0); | ||
| +} | ||
| + | ||
| +async function settled(db: D1Database, orderId: string, changed: boolean): Promise<Settled> { | ||
| + const status = await db.prepare("SELECT status FROM orders WHERE id = ?").bind(orderId).first<OrderStatus>("status"); | ||
| + if (!status) throw new Error(`no order ${orderId}`); | ||
| + return { order_id: orderId, status, changed }; | ||
| +} | ||
Addedtest/stripe-webhook.test.ts+77−0
| @@ -0,0 +1,77 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import Stripe from "stripe"; | ||
| +import { cancelOrder, markPaid } from "../src/orders.ts"; | ||
| +import { handleWebhook, type Env } from "../workers/stripe-webhook/src/webhook.ts"; | ||
| +import { NOW, setup } from "./helpers/app.ts"; | ||
| + | ||
| +const SECRET = "whsec_test_secret"; | ||
| +const CHECKOUT = { email: "ada@park.test", ship_to: { name: "Ada Park", line1: "12 Alder St", city: "Portland", postal_code: "97205", country: "US" } }; | ||
| + | ||
| +// A paid-for order waiting for its webhook, and the webhook Worker wired to the same database | ||
| +// the way the ORDERS service binding wires it in production. | ||
| +async function pendingOrder() { | ||
| + const s = setup(); | ||
| + await s.addProduct("wool-socks", { price_cents: 1800, stock: 3 }); | ||
| + const cartId = (await s.call("POST", "/carts")).body.cart.id; | ||
| + await s.call("PUT", `/carts/${cartId}/items/wool-socks`, { quantity: 2 }); | ||
| + const order = (await s.call("POST", `/carts/${cartId}/checkout`, CHECKOUT)).body.order; | ||
| + const env: Env = { | ||
| + STRIPE_WEBHOOK_SECRET: SECRET, | ||
| + ORDERS: { | ||
| + markPaid: (id, intent) => markPaid(s.env.DB, id, intent, NOW), | ||
| + cancel: (id) => cancelOrder(s.env.DB, id, NOW), | ||
| + }, | ||
| + }; | ||
| + const deliver = async (type: string, intent: Record<string, unknown>, secret = SECRET) => { | ||
| + const payload = JSON.stringify({ id: `evt_${type}`, object: "event", type, data: { object: { object: "payment_intent", ...intent } } }); | ||
| + const header = await Stripe.webhooks.generateTestHeaderStringAsync({ payload, secret, cryptoProvider: Stripe.createSubtleCryptoProvider() }); | ||
| + const req = new Request("https://hooks.shop.test/stripe/webhook", { method: "POST", body: payload, headers: { "stripe-signature": header } }); | ||
| + return handleWebhook(req, env); | ||
| + }; | ||
| + const status = async () => (await s.call("GET", `/orders/${order.id}`)).body.order.status; | ||
| + return { ...s, order, deliver, status }; | ||
| +} | ||
| + | ||
| +test("payment_intent.succeeded marks the order paid", async () => { | ||
| + const s = await pendingOrder(); | ||
| + const res = await s.deliver("payment_intent.succeeded", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } }); | ||
| + assert.equal(res.status, 200); | ||
| + assert.equal(await s.status(), "paid"); | ||
| +}); | ||
| + | ||
| +test("a repeated delivery changes nothing", async () => { | ||
| + const s = await pendingOrder(); | ||
| + const intent = { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } }; | ||
| + await s.deliver("payment_intent.succeeded", intent); | ||
| + assert.equal((await s.deliver("payment_intent.succeeded", intent)).status, 200); | ||
| + assert.equal(await s.status(), "paid"); | ||
| +}); | ||
| + | ||
| +test("an intent that is not the order's own does not pay it", async () => { | ||
| + const s = await pendingOrder(); | ||
| + await s.deliver("payment_intent.succeeded", { id: "pi_someone_else", metadata: { order_id: s.order.id } }); | ||
| + assert.equal(await s.status(), "pending_payment"); | ||
| +}); | ||
| + | ||
| +test("payment_intent.canceled cancels the order and returns the stock", async () => { | ||
| + const s = await pendingOrder(); | ||
| + await s.deliver("payment_intent.canceled", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } }); | ||
| + assert.equal(await s.status(), "cancelled"); | ||
| + assert.equal((await s.call("GET", "/products/wool-socks")).body.product.stock, 3); | ||
| + await s.deliver("payment_intent.canceled", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } }); | ||
| + assert.equal((await s.call("GET", "/products/wool-socks")).body.product.stock, 3, "only once"); | ||
| +}); | ||
| + | ||
| +test("refuses a payload that Stripe did not sign", async () => { | ||
| + const s = await pendingOrder(); | ||
| + const res = await s.deliver("payment_intent.succeeded", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } }, "whsec_wrong"); | ||
| + assert.equal(res.status, 400); | ||
| + assert.equal(await s.status(), "pending_payment"); | ||
| +}); | ||
| + | ||
| +test("acknowledges events it does not handle", async () => { | ||
| + const s = await pendingOrder(); | ||
| + assert.equal((await s.deliver("payment_intent.created", { id: "pi_1", metadata: {} })).status, 200); | ||
| + assert.equal((await s.deliver("payment_intent.succeeded", { id: "pi_2", metadata: {} })).status, 200); | ||
| +}); | ||
Addedworkers/stripe-webhook/src/index.ts+7−0
| @@ -0,0 +1,7 @@ | ||
| +import { handleWebhook, type Env } from "./webhook.ts"; | ||
| + | ||
| +export default { | ||
| + fetch(req, env) { | ||
| + return handleWebhook(req, env); | ||
| + }, | ||
| +} satisfies ExportedHandler<Env>; | ||
Addedworkers/stripe-webhook/src/webhook.ts+54−0
| @@ -0,0 +1,54 @@ | ||
| +// Stripe's webhook: verifies the signature, then settles the order the PaymentIntent belongs to. | ||
| +// Stripe retries any delivery that does not get a 2xx, for up to three days, so every outcome | ||
| +// here is either done or safe to repeat. | ||
| + | ||
| +import Stripe from "stripe"; | ||
| +import type { Settled } from "../../../src/orders.ts"; | ||
| + | ||
| +// storefront-api's OrdersService, as this Worker sees it through the ORDERS service binding. | ||
| +export interface Orders { | ||
| + markPaid(orderId: string, paymentIntentId: string): Promise<Settled>; | ||
| + cancel(orderId: string): Promise<Settled>; | ||
| +} | ||
| + | ||
| +export interface Env { | ||
| + ORDERS: Orders; | ||
| + STRIPE_WEBHOOK_SECRET: string; | ||
| +} | ||
| + | ||
| +const subtle = Stripe.createSubtleCryptoProvider(); | ||
| + | ||
| +export async function handleWebhook(req: Request, env: Env): Promise<Response> { | ||
| + if (new URL(req.url).pathname !== "/stripe/webhook") return text(404, "not found"); | ||
| + if (req.method !== "POST") return text(405, "POST only"); | ||
| + const signature = req.headers.get("stripe-signature"); | ||
| + if (!signature) return text(400, "missing stripe-signature"); | ||
| + | ||
| + let event: Stripe.Event; | ||
| + try { | ||
| + event = await Stripe.webhooks.constructEventAsync(await req.text(), signature, env.STRIPE_WEBHOOK_SECRET, undefined, subtle); | ||
| + } catch { | ||
| + return text(400, "bad signature"); | ||
| + } | ||
| + | ||
| + if (event.type === "payment_intent.succeeded" || event.type === "payment_intent.canceled") { | ||
| + const intent = event.data.object; | ||
| + const orderId = intent.metadata.order_id; | ||
| + if (!orderId) return text(200, "not a storefront payment"); | ||
| + const result = event.type === "payment_intent.succeeded" ? await env.ORDERS.markPaid(orderId, intent.id) : await env.ORDERS.cancel(orderId); | ||
| + log(event, result); | ||
| + if (event.type === "payment_intent.succeeded" && result.status === "cancelled") { | ||
| + // The money arrived for an order we had already given up on. Someone has to refund it. | ||
| + console.error(JSON.stringify({ msg: "payment for a cancelled order, refund it", order: orderId, payment_intent: intent.id })); | ||
| + } | ||
| + } | ||
| + return text(200, "ok"); | ||
| +} | ||
| + | ||
| +function log(event: Stripe.Event, r: Settled): void { | ||
| + console.log(JSON.stringify({ msg: "stripe event", type: event.type, id: event.id, order: r.order_id, status: r.status, changed: r.changed })); | ||
| +} | ||
| + | ||
| +function text(status: number, body: string): Response { | ||
| + return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } }); | ||
| +} | ||
Addedworkers/stripe-webhook/wrangler.jsonc+11−0
| @@ -0,0 +1,11 @@ | ||
| +// storefront-stripe-webhook: receives Stripe's webhook events at /stripe/webhook. It has no | ||
| +// database binding; it settles orders through storefront-api's OrdersService. | ||
| +// Secret: STRIPE_WEBHOOK_SECRET (npx wrangler secret put STRIPE_WEBHOOK_SECRET). | ||
| +{ | ||
| + "$schema": "../../node_modules/wrangler/config-schema.json", | ||
| + "name": "storefront-stripe-webhook", | ||
| + "main": "src/index.ts", | ||
| + "compatibility_date": "2026-09-01", | ||
| + "observability": { "enabled": true }, | ||
| + "services": [{ "binding": "ORDERS", "service": "storefront-api", "entrypoint": "OrdersService" }] | ||
| +} | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.