One file:
apps/api/src/lib/fulfillment/invite-collaborator.ts
Everything else in that folder is delivery machinery, and none of it knows what a GitHub repository is. The claim, the classification, the flush, the state column, both requeue routes and the dashboard panel all come along unchanged.
Two supporting edits follow from it: the environment variables the new seam reads, and the invite email if you keep one.
What the seam has to return
export type InviteOutcome =
| { kind: 'delivered' }
| { kind: 'terminal'; reason: string }
| { kind: 'transient'; reason: string }
This classification is the contract, not the HTTP call. Get it wrong and either a mistyped input is retried forever or a vendor outage is recorded as a permanent failure.
| Outcome | Means | What the flush does |
|---|---|---|
delivered |
the customer has the thing | sets delivered and fulfilled_at, sends the mail |
terminal |
it will never work | sets failed on the first failure, out of the queue |
transient |
it might work later | sets the row back to pending and stops the flush |
The dividing line is not the status code, it is whether retrying could ever produce a different answer.
404 on a GitHub handle is terminal, because retrying a typo never succeeds. 403 is transient, because that is where GitHub's 50-invites-per-24-hours cap lands. A timeout is transient. A validation failure on input the customer typed is terminal.
There is deliberately no attempt cap on the transient class. One vendor outage would burn the budget on rows that were never bad and then stop retrying them forever, so a retry is ended by classifying the failure as terminal rather than by running out of attempts. Adding a cap swaps a correct failure mode for a plausible one.
Replace the file
// apps/api/src/lib/fulfillment/invite-collaborator.ts
import { requireConfigured } from '../../env'
import { describeError } from '../errors'
export type GithubConfig = {
token: string
owner: string
name: string
}
export function githubConfig(): GithubConfig {
return {
token: requireConfigured('GITHUB_TOKEN'),
owner: requireConfigured('GITHUB_REPO_OWNER'),
name: requireConfigured('GITHUB_REPO_NAME'),
}
}
Keep both exports and their shapes, and change what is inside them.
githubConfig() is resolved once per flush, before any row is claimed. That ordering matters: a deployment with no token would otherwise claim a row and then fail on it, and a row claimed by a process that cannot fulfill it is a row an operator has to release by hand.
Read every value it needs together. A deployment holding a token but no target is not configured either, and letting it through trades a clear 501 for a downstream 404 that reads like a customer's typo.
Rename both exports to suit your product. They are internal to the folder and are not re-exported from its barrel, so the only call sites are in flush-fulfillments.ts.
Write the call
Change the request in the existing inviteCollaborator function and keep its
outcome classification beside the call. The current failure branch is:
const message = await githubMessage(response)
const reason = truncate(
message === undefined
? `GitHub responded ${response.status}.`
: `GitHub responded ${response.status}: ${message}`,
)
return response.status === 404 || response.status === 422
? { kind: 'terminal', reason }
: { kind: 'transient', reason }
Four things in there are worth keeping.
An idempotent operation. Repeating the same request must preserve the intended outcome. The HTTP verb alone does not prove that contract; use the vendor's documented idempotency behavior or key. Inviting somebody who already has access returns 204, which the shipped code treats as delivered, because a resend of an accepted invite is success. The reference accepted only 201, so re-inviting somebody who already had access looked like a failure forever.
A timeout. The flush runs inside a webhook handler that Stripe and svix both time out on and then redeliver. A hung socket would hang the flush; the shipped timeout is 10 seconds and a timeout is transient, so the row goes straight back in the queue.
describeError, never the error object. reason is written into fulfillment_last_error, which is a column an admin reads. JSON.stringify on an HTTP client error walks err.config.headers and puts your bearer token in that column and in your logs.
A truncated reason. Long enough for the vendor's own message, short enough that a runaway body cannot fill a column somebody has to read. The shipped limit is 200 characters, and the string has to say Not Found rather than request failed, because that is the difference between "fix the handle" and "open a support ticket".
Encode whatever the customer typed
const url = `https://api.example.com/tenants/${encodeURIComponent(handle)}`
The GitHub username is checked against GitHub before payment, but it is still the one value in that URL a stranger chose, and the check is skipped whenever the vendor cannot answer.
Change the environment variables
Drop GITHUB_TOKEN, GITHUB_REPO_OWNER and GITHUB_REPO_NAME from apps/api/src/env.ts and add your own, in the optional tier, read through requireConfigured:
TENANT_API_KEY: z.string().min(1).optional(),
TENANT_API_URL: z.url().optional(),
Optional is the right tier. A buyer running your repo without your vendor's key should boot, and reaching fulfillment without it should be a 501 that names the variable rather than a crash. flushFulfillments() already catches that 501 specifically and returns abortedBy: 'not_configured', so nothing else changes.
Update apps/api/.env.example at the same time. It is the inventory a buyer reads.
Change what you collect at checkout
Stripe's page collects nothing. The GitHub username is typed at /checkout, a step of your own between sign-in and Stripe, and arrives in the body of POST /checkout/session under the shared contract in packages/shared/src/requests.ts:
export const checkoutSessionRequestSchema = z.object({
slug: z.enum(SLUGS),
locale: localeSchema,
githubHandle: githubHandleSchema.nullable(),
})
null is the customer who has no GitHub account: the sale completes and the delivery parks until a username arrives. apps/api/src/routes/checkout/post-session.ts existence-checks a non-null value before it creates the Session, then passes it in Session metadata, and record-paid-objects.ts reads it back off the re-fetched Session and writes purchases.github_handle.
Four edits move that to your own input:
-
Rename the field in the shared schema
packages/shared/src/requests.tsowns the wire contract, so the form and the handler change together and a mismatch is a type error rather than a silently missing value. -
Replace the pre-payment check
apps/api/src/lib/fulfillment/check-github-handle.tsreadsGET /users/{username}and rejects only a value the vendor actively denies. Everything else accepts and logsfulfillment.handle.unverified: an unset token, a rate cap, a timeout and a 5xx all let the sale through. Keep that asymmetry, because a validator that is down must not stop a payment. Let the vendor judge the value rather than pattern-matching it locally first, for the same reason: a local rule that is wrong refuses a sale outright. -
Rename the metadata key and the column
The key travels from
post-session.tstorecord-paid-objects.ts. Change the schema, runpnpm db:generate, and review the delta. -
Relabel the two web surfaces
apps/web/src/components/GithubHandleField/GithubHandleField.tsxis the one input, used by the checkout step inapps/web/src/components/Checkout/Checkout.tsxand by the correction inapps/web/src/components/Dashboard/InviteStatusPanel/InviteStatusPanel.tsx. Both render the api's own message for a rejected value, so a new vendor's wording arrives without a web change. What does need changing is the label, the placeholder, the prefill, and the copy on the escape control that proceeds with a null value.The prefill reads the buyer's Clerk GitHub external account. If your vendor is not a Clerk provider there is nothing to prefill from, so delete that branch and keep the typed field.
If your fulfillment needs nothing from the customer, drop the field, the check and the column, and the checkout step becomes a confirmation. If it needs a value no vendor can confirm in advance, drop the check and keep the correction route: POST /fulfillment/requeue lets the buyer replace the value on a failed purchase they own, and it refuses an unchanged value and a second call inside five minutes.
Guest checkout is still available to you. Remove requireUser from apps/api/src/routes/checkout/index.ts and a stranger can pay. You give up two things in exchange: the charge email stops being locked to the account, because that lock comes from passing an existing Stripe Customer, and the Purchase stops arriving with an owner, because client_reference_id and the Customer both come from the signed-in caller. A fork whose fulfillment is not account-bound owns that mismatch and needs its own way to reunite a payment with a person. See docs/adr/0031-identity-precedes-payment.md.
Change the email, or drop it
sendInvite is called after the row is marked delivered, and it names the repository the caller actually invited to. If you keep a mail, keep that ordering and keep passing the target in as props rather than reading it from the environment inside the template.
If you drop it, delete the call in flush-fulfillments.ts and the template. The mail is a courtesy; the fulfillment already happened.
What the existing delivery machinery preserves
Everything in this list keeps working without a line changed:
- an exclusive claim under concurrent webhook deliveries, from one state column and one locked row per statement
- the four states,
pending,in_progress,deliveredandfailed, each written where the outcome is known - the four triggers: the Stripe webhook, Clerk
user.createdoruser.updated, the buyer's own correction, and the admin re-trigger - both requeue routes, which set the row back to
pendingand flush:POST /fulfillment/requeuefor the buyer andPOST /admin/users/:id/fulfillment/retriggerfor an admin. Each is one conditional update matchingpendingorfailed, so it refuses a row a flush still owns, and the admin route takesforceto override that, for a row stranded by a crash or an invite that already landed - the buyer's own view at
GET /fulfillment, which never seeslastError - the flush reporting rather than throwing, so a vendor outage leaves the purchase recorded and the delivery queued
Verify
pnpm typecheck
pnpm check
Then, with the vendor variables unset, complete a sandbox checkout. The log should carry fulfillment.skipped with reason: 'not_configured', the purchase should be recorded, and no row should be claimed.
Set the variables and press re-send on the admin panel. Watch for fulfillment.delivered, then re-send again: the second attempt should also be delivered, because the call is idempotent. If it is failed the second time, check the vendor's repeat-request behavior and
your outcome classification.
Then break it on purpose. Point the URL at a host that does not resolve and confirm the row comes back pending rather than failed, because a DNS failure is transient.
Related
- Fulfillment, the whole mechanism
docs/adr/0038-fulfillment-state-is-a-column.md, the state column and the one-row claimdocs/adr/0007-atomic-claim-for-side-effects.mddocs/adr/0013-api-hardening-baseline.md, the retry classificationdocs/adr/0031-identity-precedes-payment.md, where the customer's input is collected and checked