Sync Stripe subscriptions to Plain.com tenants
Keep Plain.com customers, tenants and tenant tiers in sync with Stripe subscription state using a single Webhook Relay function: verify the Stripe signature, map subscription events to Plain GraphQL mutations and answer Stripe — no server, no Cloudflare Worker.
This tutorial replaces a "Stripe → Plain.com" sync worker (for example a Cloudflare Worker) with a single Webhook Relay function attached to a bucket input. Stripe delivers subscription events to the bucket's permanent input URL; the function verifies the Stripe signature, looks up the customer, and upserts Plain.com customers, tenants and tenant tiers through Plain's GraphQL API — no server to deploy, no public endpoint of your own to secure. Every event is logged in the bucket and can be replayed.
The pattern works for any "webhook in, GraphQL out" integration — only the mutations change.
1. Create a bucket for Stripe events
In my.webhookrelay.com/buckets create a bucket named stripe. Its default input URL (https://xxx.hooks.webhookrelay.com/v1/web/...) is permanent and public — that is the endpoint Stripe will deliver to. No output destinations are needed.
2. Store the credentials as secret connections
The function needs three credentials. Create each as a Secret service connection at my.webhookrelay.com/service-connections — secrets are write-only, encrypted at rest and redacted from logs, unlike values pasted into source code:
| Alias in the function | Holds |
|---|---|
secret:stripe-signing-secret | Stripe webhook signing secret (whsec_... from the webhook endpoint settings) |
secret:stripe-api-key | Stripe secret API key (used to look up the customer's email on subscription events) |
secret:plain-api-key | Plain.com API token |
How secret imports and bindings work: Secrets.
3. Create the function
Go to my.webhookrelay.com/functions, create a function named stripe-to-plain (driver JavaScript) and paste in the code below. Then open the function's Connections tab and bind each secret: alias to the connection from step 2.
// Stripe → Plain.com sync. Attached to a bucket INPUT: verifies Stripe's
// signature, keeps Plain customers/tenants/tiers in sync with subscription
// state, and answers Stripe directly. No output destination required.
// Bound on the function's Connections tab — never hard-code these:
const stripeSigningSecret = require("secret:stripe-signing-secret") // whsec_...
const stripeApiKey = require("secret:stripe-api-key") // sk_...
const plainApiKey = require("secret:plain-api-key") // plainApiKey_...
// Non-secret settings (function config, not secrets):
// PLAIN_PAID_TIER_ID – Plain tenant tier id that marks a tenant as paid
// (Plain → Settings → Tenants → Tiers)
// DRY_RUN – unset or "true" = log only, "false" = write to Plain
const PLAIN_API = "https://core-api.uk.plain.com/graphql/v1"
const SIGNATURE_TOLERANCE_S = 300
const PAID_STATUSES = ["active", "trialing", "past_due"]
const HANDLED_EVENTS = [
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.created"
]
// ---------- Plain GraphQL ----------
const UPSERT_CUSTOMER = `
mutation UpsertCustomer($input: UpsertCustomerInput!) {
upsertCustomer(input: $input) {
customer { id email { email } }
error { message code }
}
}`
const UPSERT_TENANT = `
mutation UpsertTenant($input: UpsertTenantInput!) {
upsertTenant(input: $input) {
tenant { id name externalId }
error { message code }
}
}`
const UPDATE_TENANT_TIER = `
mutation UpdateTenantTier($input: UpdateTenantTierInput!) {
updateTenantTier(input: $input) {
error { message code }
}
}`
const ADD_CUSTOMER_TO_TENANTS = `
mutation AddCustomerToTenants($input: AddCustomerToTenantsInput!) {
addCustomerToTenants(input: $input) {
customer { id }
error { message code }
}
}`
function plain(query, variables) {
const resp = http.request("POST", PLAIN_API, {
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + plainApiKey
},
body: JSON.stringify({ query: query, variables: variables })
})
if (resp.status_code !== 200) {
throw new Error("Plain HTTP " + resp.status_code + ": " + resp.body)
}
const body = JSON.parse(resp.body)
if (body.errors && body.errors.length > 0) {
throw new Error("Plain GraphQL: " + body.errors.map(e => e.message).join("; "))
}
if (!body.data) throw new Error("Plain returned empty data")
return body.data
}
function upsertPlainCustomer(email, fullName, stripeCustomerId) {
const data = plain(UPSERT_CUSTOMER, {
input: {
identifier: { emailAddress: email },
onCreate: {
fullName: fullName || email,
email: { email: email, isVerified: false },
externalId: stripeCustomerId
},
onUpdate: {}
}
})
if (data.upsertCustomer.error) {
throw new Error("upsertCustomer: " + data.upsertCustomer.error.message)
}
return data.upsertCustomer.customer.id
}
function upsertPlainTenant(stripeCustomerId, name) {
const data = plain(UPSERT_TENANT, {
input: {
identifier: { externalId: stripeCustomerId },
name: name,
externalId: stripeCustomerId
}
})
if (data.upsertTenant.error) {
throw new Error("upsertTenant: " + data.upsertTenant.error.message)
}
return data.upsertTenant.tenant.id
}
function setPlainTenantTier(tenantId, tierId) {
if (!tierId) throw new Error("PLAIN_PAID_TIER_ID is not set in function config")
plain(UPDATE_TENANT_TIER, {
input: {
tenantIdentifier: { tenantId: tenantId },
tierIdentifier: { tierId: tierId }
}
})
}
function clearPlainTenantTier(tenantId) {
plain(UPDATE_TENANT_TIER, {
input: {
tenantIdentifier: { tenantId: tenantId },
tierIdentifier: null
}
})
}
function linkPlainCustomerToTenant(customerId, tenantId) {
plain(ADD_CUSTOMER_TO_TENANTS, {
input: {
customerIdentifier: { customerId: customerId },
tenantIdentifiers: [{ tenantId: tenantId }]
}
})
}
// ---------- Stripe ----------
function fetchStripeCustomer(customerId) {
const resp = http.request(
"GET",
"https://api.stripe.com/v1/customers/" + customerId,
{ headers: { Authorization: "Bearer " + stripeApiKey } }
)
if (resp.status_code !== 200) {
throw new Error("Stripe customer lookup " + resp.status_code + ": " + resp.body)
}
return JSON.parse(resp.body)
}
// ---------- main ----------
function answer(status, payload) {
r.setResponseStatus(status)
r.setResponseHeader("Content-Type", "application/json")
r.setResponseBody(JSON.stringify(payload))
r.stopForwarding()
}
// 1) Verify Stripe's signature before trusting the payload.
// Stripe-Signature: t=<unix ts>, v1=<hex hmac of "<ts>.<body>">
const signature = r.headers["Stripe-Signature"] || ""
const timestamp = (signature.match(/t=(\d+)/) || [])[1]
const sent = (signature.match(/v1=([a-f0-9]+)/) || [])[1]
if (!timestamp || !sent) {
answer(400, { error: "missing Stripe-Signature header" })
return
}
if (Math.abs(time.unix() - parseInt(timestamp, 10)) > SIGNATURE_TOLERANCE_S) {
answer(400, { error: "timestamp outside tolerance" })
return
}
const expected = crypto.hmac("sha256", stripeSigningSecret, timestamp + "." + r.body)
if (expected !== sent) {
answer(400, { error: "signature mismatch" })
return
}
// 2) Parse the event; ignore anything we do not sync.
let event
try {
event = JSON.parse(r.body)
} catch (e) {
answer(400, { error: "invalid JSON body" })
return
}
if (HANDLED_EVENTS.indexOf(event.type) === -1) {
answer(200, { received: true, handled: false, type: event.type })
return
}
// 3) Sync. Any Plain error → 500 so Stripe retries the delivery.
try {
const outcome = syncEvent(event)
console.log("stripe-to-plain", event.type, JSON.stringify(outcome))
answer(200, { received: true, handled: true, outcome: outcome })
} catch (e) {
console.error("stripe-to-plain error", event.type, String(e))
answer(500, { error: String(e) })
}
// ---------- sync ----------
function syncEvent(event) {
let email, name, stripeCustomerId, tier
if (event.type === "customer.created") {
// New Stripe customer: make sure they exist in Plain on the free tier.
const c = event.data.object
if (!c.email) return { action: "noop", reason: "customer_has_no_email" }
email = c.email
name = c.name
stripeCustomerId = c.id
tier = "free"
} else {
// Subscription events: resolve the customer for email + name.
const sub = event.data.object
stripeCustomerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id
const customer = fetchStripeCustomer(stripeCustomerId)
if (!customer.email) {
return { action: "noop", reason: "stripe_customer_has_no_email" }
}
email = customer.email
name = customer.name
if (event.type === "customer.subscription.deleted") {
tier = "free"
} else if (PAID_STATUSES.indexOf(sub.status) !== -1) {
tier = "paid"
} else {
tier = "free"
}
}
const action = tier === "paid" ? "set_paid" : "set_free"
// DRY_RUN unset or "true" = log only; set DRY_RUN=false to write to Plain.
if (cfg.get("DRY_RUN") !== "false") {
return { action: action, email: email, reason: "dry_run" }
}
const customerId = upsertPlainCustomer(email, name, stripeCustomerId)
const tenantId = upsertPlainTenant(stripeCustomerId, email)
if (tier === "paid") {
setPlainTenantTier(tenantId, cfg.get("PLAIN_PAID_TIER_ID"))
} else {
clearPlainTenantTier(tenantId)
}
linkPlainCustomerToTenant(customerId, tenantId)
return { action: action, email: email, plain_customer_id: customerId }
}
Notes on the APIs used, all covered in the functions reference:
require("secret:...")resolves secret connections before the code runs; an unbound alias fails fast.http.requestis synchronous and returns{ body, status_code, headers }— see make HTTP requests.crypto.hmac("sha256", key, message)returns a hex digest, matching Stripe'sv1=signature — see crypto functions.time.unix()gives the current Unix time in seconds for the tolerance check.answer()uses response control (setResponseStatus/setResponseBody) plusr.stopForwarding(), so Stripe is answered by the input itself.
4. Attach the function to the input
Open the stripe bucket, find the input's settings and select stripe-to-plain as its function. An input-attached function is required here: functions attached to an output cannot customize the response returned to Stripe.
5. Point Stripe at the bucket
In the Stripe dashboard add a webhook endpoint with the bucket's input URL and subscribe to the events the function handles:
customer.createdcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deleted
Stripe shows you the signing secret (whsec_...) for this endpoint — that is the value for the secret:stripe-signing-secret binding. For testing against your live account first, the same flow works with a Stripe CLI alternative or a local receiver.
6. Test it, dry run first
The function ships safe by default: with DRY_RUN unset it only logs what it would do. In the function editor (or with relay function invoke), send a sample subscription event and check the logs — you should see:
{ "action": "set_paid", "email": "[email protected]", "reason": "dry_run" }
Then set PLAIN_PAID_TIER_ID in the function's config, flip DRY_RUN to false, and trigger a real event (for example toggle a test subscription in Stripe). The customer appears in Plain, the tenant is created with the Stripe customer ID as its external ID, and the paid tier is set. In the bucket's request log you can open any past delivery and replay it — the Plain mutations are upserts, so a replay converges to the same state instead of duplicating anything.
What each event does
| Stripe event | Plain effect |
|---|---|
customer.created | Customer upserted by email, tenant created (external ID = Stripe customer ID), tier cleared (free) |
customer.subscription.created / .updated with status active, trialing or past_due | Customer + tenant upserted, paid tier set |
customer.subscription.updated with any other status (unpaid, canceled, ...) | Tier cleared |
customer.subscription.deleted | Tier cleared |
| Any other event | 200 to Stripe, nothing synced |
Why this beats a worker for this job
- Nothing to deploy. The function lives next to the bucket; no CI, no wrangler, no worker URL to rotate.
- Every event is kept. Stripe deliveries land in the bucket's log with full request/response bodies, so you can audit and replay a missed sync — the upserts make replays safe.
- Secrets stay write-only. Credentials live in secret connections, not in source or environment variables that leak into logs.
- Retries are handled for you. A Plain outage returns a 500 to Stripe, which retries; a recovered event replays from the log without waiting for Stripe.
If you also want the raw events somewhere durable (a warehouse, a queue, an internal API), keep the bucket's input as-is and add an output per destination — the input function still answers Stripe, and each output can have its own function or forwarding rules.
Frequently asked questions
Do I need a destination output for this to work?
No. The function is attached to the bucket's input and calls the Plain GraphQL API directly with http.request, then answers Stripe itself with r.stopForwarding(). The bucket needs no output destinations; every request is still logged and replayable.
Which Stripe subscription statuses count as paid?
active, trialing and past_due set the paid tenant tier. Everything else — including customer.subscription.deleted, canceled and unpaid — clears the tier back to none.
What does Stripe see when Plain is down?
The function returns a 500, so Stripe retries the event on its normal schedule. Signature failures and malformed bodies return a 400, which Stripe does not retry. Successful handling returns a 200 with a JSON body.
Is the sync idempotent when Stripe retries the same event?
Yes. Every Plain mutation is an upsert keyed on the customer's email address and the Stripe customer ID as the tenant external ID, so replaying an event or re-running it from the bucket log converges to the same state.
