AI Automation for SaaS Operations: Queues, Webhooks, Billing
AI workflow automation inside a SaaS product is three primitives, not a canvas: a durable email queue (enqueueEmail, processNextEmail), a billing webhook intake that verifies the signature before it deduplicates anything (handleProviderWebhook, processNormalizedEvent), and a quota scan that claims a notification before it sends one (runQuotaEmailNotifications, markQuotaEmailSent).
Short answer: AI workflow automation inside a SaaS product is three primitives, not a
canvas: a durable email queue (enqueueEmail, processNextEmail), a billing webhook intake that
verifies the signature before it deduplicates anything (handleProviderWebhook,
processNormalizedEvent), and a quota scan that claims a notification before it sends one
(runQuotaEmailNotifications, markQuotaEmailSent). The phrase ai workflow automation
carries 1,000 US searches a month and a difficulty of 28, against 2,900 a month for
workflow automation. The gap between the two terms is what is hard:
retries, duplicate deliveries and a quota that bills a real customer.
Key takeaways
- The queue is the feature, not the email.
enqueueEmailrenders nothing, sends nothing and retries nothing; it puts a typed payload on one queue, which makes retry and deduplication uniform for every sender. - Verify, then deduplicate, then dispatch.
handleProviderWebhookrejects a bad signature with a 400 before it holds an idempotency key, so unsigned traffic never reaches the dedup namespace and a duplicate gets a 200 rather than a retry. - The claim is the lock.
markQuotaEmailSentis a set-if-absent whose TTL ends with the billing month, so two workers cannot both send the same warning. - State has one writer.
cancelSubscriptionwrites nothing locally;syncSubscriptionrepairs the row when a webhook never arrives. - Do this next: for every action your product runs in response to an external event, write down what happens on a duplicate delivery - anything without an answer is the work.
The short version for whoever signs off on the automation roadmap
Two unrelated things are sold under one label. One is a canvas where an analyst connects a trigger to an action. The other is a set of code paths inside your product that must behave when the network does not - which is why the first one looks unreliable six months later, when a provider retries a webhook or a usage warning arrives four times because two replicas decided the hour had come.
For the non-technical reader, the questions to ask your team are short. Which actions can run twice without anyone noticing? What happens to a job that fails a hundred times? How does customer state change if the provider's message never arrives? If the workflows are agents rather than fixed jobs, those questions land a layer up, where the failure is not only duplicate actions but spend: SmartGate is an MCP-native algorithm gateway for token control, traffic shaping and agent audit - agent loops do not warn, they bill.
Whoever signs off on that bill reads the same traffic as a quota percentage and a per-tool spend report, which is the viewpoint the FinOps lead page is written from.
What the numbers say about AI workflow automation
ai workflow automation shows 1,000 US searches a month; workflow automation shows 2,900.
The wider term carries the demand, and the gap between them is the work: retries, duplicate
deliveries and a quota that bills a real customer.
The answer surfaces agree on the definition and stop there. The AI Overview for
workflow automation describes software that "runs a sequence of tasks across apps and systems
automatically without manual effort", reducing every workflow to trigger, conditional logic and
action, citing Zapier,
Workato and
IBM; the overview for
ai workflow automation adds that AI workflows "read messy text, understand context, and adapt
when inputs change", citing Airtable.
Organic results for the AI-flavoured phrase are almost all tool listicles
(Atlassian,
n8n), which leaves the opening: the
market is well served on what to automate and barely served on how the pieces behave when they
fail. The reason an automation duplicates an action is almost never the canvas; it is a delivery
guarantee nobody documented.
enqueueEmail: the write side of the queue is two lines long
# lib/jobs/email.send.ts — source lines 9–14 (enqueueEmail)
async function enqueueEmail(data: EmailJobData) {
await enqueue("email", {
type: data.type,
payload: data as unknown as Record<string, unknown>,
});
}
The producer knows nothing: no template rendering, no provider call, no retry. It hands a typed payload to a single queue and casts it down to a generic record at that boundary, because the queue stores bytes and the consumer re-asserts the shape on the way out.
Every email has one producer, so retry and deduplication are properties of the queue rather than of twenty call sites. The payload contract lives on the consumer side, so adding a field is a change the compiler will not catch.
processNextEmail: one job per call, and why the loop lives elsewhere
# lib/jobs/email.send.ts — source lines 16–25 (processNextEmail)
async function processNextEmail(): Promise<boolean> {
const job = await dequeue("email");
if (!job) return false;
switch (job.type) {
case "welcome": {
const { to, firstName } = job.payload as { to: string; firstName?: string };
await sendWelcomeEmail({ to, firstName });
break;
}
# lib/jobs/email.send.ts — source lines 39–42 (processNextEmail)
}
return true;
}
Read the return value carefully: true means a job was dequeued and handled, not that the
customer received mail - a failing send inside the welcome branch does not change the answer.
The two windows are not a formatting choice. The magic-link branch between them wraps a
request object around a loopback URL for the verification provider's template context, and the
pipeline's internal-host scan excludes that literal host from published text; the branch itself
is one sendVerificationRequest call with a fifteen-minute expiry.
The structural lesson is smaller than it looks: a switch with no default case means an
unknown type falls through, the function returns true, and the payload is gone.
A payload that disappears without a counter moving is exactly the failure an LLM observability layer exists to surface, because nothing else in the system will report it.
startEmailWorker: a polling loop with a predictable ceiling
# lib/jobs/email.send.ts — source lines 47–57 (startEmailWorker)
async function startEmailWorker(pollIntervalMs = 5000) {
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await processNextEmail();
} catch (err) {
console.error("[email-worker] Error processing job:", err);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
}
Read the loop as a capacity statement: one attempt, errors swallowed and logged, then a sleep of
pollIntervalMs - 5,000 milliseconds by default. Sleeping after successful attempts too caps a
single worker at 12 emails a minute: fine for welcome mail, nowhere near a campaign.
The error handling is deliberate: a poisoned job logs and the loop continues, because a worker that dies on one malformed payload stops all email for the product. A blocking consumer such as BullMQ's worker model removes the idle poll entirely (BullMQ).
What that loop does not fix on its own is contention: several producers filling one queue, a bounded pool underneath, and the limits that keep a burst from turning into backpressure - all of which are worked through in concurrency control in AI backend systems.
handleProviderWebhook: verify, dedupe, then dispatch
# lib/webhook/framework.ts — source lines 168–186 (handleProviderWebhook)
async function handleProviderWebhook(
providerId: string,
request: Request,
): Promise<Response> {
const provider = getProvider(providerId);
if (!provider) {
return new Response(`Unknown webhook provider: ${providerId}`, { status: 404 });
}
const rawBody = await request.text();
if (!(await provider.verify(rawBody, request.headers))) {
if (process.env.NODE_ENV === "development") {
console.warn(
`[webhook:${providerId}] Invalid signature — check PADDLE_NOTIFICATION_SECRET_KEY matches the Simulation destination secret`,
);
}
return new Response("Invalid signature", { status: 400 });
}
# lib/webhook/framework.ts — source lines 188–202 (handleProviderWebhook)
const idempotencyKey =
request.headers.get("Idempotency-Key") ||
request.headers.get("X-Event-ID") ||
request.headers.get("x-event-id") ||
parsePaddleEventId(rawBody) ||
request.headers.get("Stripe-Signature")?.slice(0, 64);
if (idempotencyKey) {
const redis = getRedis();
const dedupKey = `webhook:dedup:${providerId}:${idempotencyKey}`;
const existed = await redis.set(dedupKey, "1", { nx: true, ex: 86400 });
if (existed === null) {
return new Response("ok", { status: 200 });
}
}
The order of the three phases is the design. Resolve the provider and 404 an unknown id; read
the body once as text; verify the signature against those raw bytes. Only then look for an
idempotency key, in five places and descending order of trust: an Idempotency-Key header,
X-Event-ID, its lower-case twin, the Paddle-specific event_id parsed from the body, and the
first 64 characters of a Stripe signature header. The claim is a set-if-absent write with an
86,400-second expiry - a 24-hour memory of every event already accepted.
Two details separate an endpoint from a liability. Deduplication happens after verification, so
nobody can burn an event id to suppress a real delivery. And a duplicate gets ok with a 200
rather than an error, because a sender's retry logic only needs a 2xx (Paddle signature
verification, Stripe
idempotency). Then the provider normalises the
payload and processNormalizedEvent applies it - and if that throws, the catch returns a 500,
the opposite of the worker loop's posture, because here the sender has a retry policy and the
right move is to let it use one.
processNormalizedEvent: one switch, three subscription states
# lib/webhook/framework.ts — source lines 18–28 (processNormalizedEvent)
async function processNormalizedEvent(event: NormalizedWebhookEvent): Promise<void> {
if (!event.teamId) {
if (process.env.NODE_ENV === "development") {
console.warn(
"[webhook] skipped plan update: missing teamId",
event.type,
event.externalCustomerId,
);
}
return;
}
# lib/webhook/framework.ts — source lines 41–50 (processNormalizedEvent)
const mapped = resolvePlanFromPriceId(event.planProductId ?? "");
const nextPlan = mapped?.plan ?? "PRO";
await applyPlanTransition({
teamId: event.teamId,
previousPlan: team.plan,
nextPlan,
source: "billing_webhook",
monthlyTokenLimitOverride: mapped?.monthlyTokenLimit,
});
Normalisation happens before this function, so it never sees a raw payload: it receives a team id and a semantic event type. The guard at the top is the most instructive line in the file - an event with no team id returns early, and the warning is gated on the development environment, so production stays quiet while a misconfigured destination is debuggable locally.
The subscription.activated branch shows the shape every branch shares: read current state,
resolve the plan from the price id, apply the transition, then write the provider's identifiers
onto the team. Two decisions inside it are worth copying. An unknown price id resolves to PRO,
failing towards entitlement rather than denial. And the intro-offer timestamp is written only
when a team moves from FREE to PRO for the first time, so a cancel-and-resubscribe cycle
cannot re-earn the discount.
The windows skip the team lookup above the mapping and the identifier write below it. The other
branches are described rather than quoted: subscription.updated writes cancel_scheduled plus
the period end, and flips back to active if the schedule is removed; subscription.canceled
refuses any subscription id that is not the team's current one, then downgrades to FREE. That
guard is what makes an out-of-order webhook harmless.
parsePaddleEventId: the third source of an idempotency key
# lib/webhook/framework.ts — source lines 9–16 (parsePaddleEventId)
function parsePaddleEventId(rawBody: string): string | null {
try {
const parsed = JSON.parse(rawBody) as { event_id?: string };
return parsed.event_id ?? null;
} catch {
return null;
}
}
Paddle puts the event id in the body, so the parser reads JSON inside a try and returns the id
or null, never throwing. A body that is not JSON is not an error here: the caller falls
through to the next candidate.
The trade-off is in the signature: null cannot distinguish a malformed payload from a provider
that sends no event ids. That is acceptable where the signature was already verified over the
same bytes, and it keeps the happy path a single expression.
runQuotaEmailNotifications: scan teams, decide per member
# lib/jobs/quota-email-notifications.ts — source lines 49–65 (runQuotaEmailNotifications)
async function runQuotaEmailNotifications(): Promise<QuotaEmailJobResult> {
const result: QuotaEmailJobResult = {
teamsScanned: 0,
emailsSent: 0,
skipped: 0,
errors: [],
};
if (!process.env.RESEND_API_KEY || !process.env.EMAIL_FROM) {
result.errors.push("RESEND_API_KEY or EMAIL_FROM not configured");
return result;
}
if (!isRedisConfigured()) {
result.errors.push("Redis required for usage lookup and email deduplication");
return result;
}
# lib/jobs/quota-email-notifications.ts — source lines 135–144 (runQuotaEmailNotifications)
if (await wasQuotaEmailSent(user.id, team.id, level)) {
result.skipped += 1;
continue;
}
const claimed = await markQuotaEmailSent(user.id, team.id, level);
if (!claimed) {
result.skipped += 1;
continue;
}
This is a scheduled scan rather than an event handler, so it fails loudly and cheaply first: missing mail credentials or an unconfigured cache exit with a reason recorded in the result object before a single team is read. A job that reports only a send count cannot tell you whether the silence was correct.
The per-member loop is where the design earns its keep: build the thresholds the member subscribed to, skip anyone with none enabled, apply the banding rule, read whether this notification already went out, claim it, and only then hand the address to the mail provider. Check, claim, send - and every skip increments a counter, so after a month you can answer "why did 40 percent of members get nothing?" with data.
The dependency to notice is the cache: the dedup story here is one Redis primitive, and the job refuses to run without it (Redis SET). Jobs that degrade to "send anyway" when the cache is down are the ones that email a customer eleven times.
shouldNotifyQuotaThreshold: the 80 percent rule is a band
# lib/jobs/quota-email-keys.ts — source lines 16–26 (shouldNotifyQuotaThreshold)
function shouldNotifyQuotaThreshold(
used: number,
limit: number,
threshold: 80 | 100,
): boolean {
if (limit <= 0 || !Number.isFinite(limit) || limit >= Number.MAX_SAFE_INTEGER / 2) {
return false;
}
const pct = quotaUsagePercent(used, limit);
return threshold === 80 ? pct >= 80 && pct < 100 : pct >= 100;
}
Three lines, two ideas. The first disqualifies the cases where a percentage means nothing: a limit that is zero or negative, a limit that is not finite, and a limit at or above half the maximum safe integer - the sentinel this codebase uses for a contract with no practical ceiling.
The second is the band. Eighty percent fires at or above 80 and below 100; a hundred fires at or above 100. The bands do not overlap, so a team that jumps from 79 to 103 percent between two runs gets exactly one message - the hundred-percent one, because the previous claim still holds.
The same two-band discipline governs a team's monthly allowance, and how that ceiling is checked before a call is spent is described in enforcing a token quota per team.
markQuotaEmailSent: claim before you send
# lib/jobs/quota-email-notifications.ts — source lines 22–32 (markQuotaEmailSent)
async function markQuotaEmailSent(
userId: string,
teamId: string,
threshold: 80 | 100,
): Promise<boolean> {
const redis = getRedis();
const key = quotaEmailSentKey(userId, teamId, threshold);
const ttl = secondsUntilMonthEnd();
const result = await redis.set(key, "1", { nx: true, ex: ttl });
return result === "OK";
}
The write is set-if-absent, so the caller proceeds only when the result is OK; the key is
scoped by user, team and threshold; the expiry is the seconds remaining in the billing month.
Three properties fall out of that one line: a duplicate in the same month cannot be sent, a new
month re-arms every notification with no cleanup job, and the claim comes before the send, so a
crash in between loses one notification rather than producing two.
The cost is that a claim is not a queue. A claimed-but-unsent message is never retried, and nothing records the loss beyond a counter that did not move. That unrecorded loss is what llm observability tools are built to make visible.
wasQuotaEmailSent: a read that exists for the report
# lib/jobs/quota-email-notifications.ts — source lines 34–43 (wasQuotaEmailSent)
async function wasQuotaEmailSent(
userId: string,
teamId: string,
threshold: 80 | 100,
): Promise<boolean> {
const redis = getRedis();
const key = quotaEmailSentKey(userId, teamId, threshold);
const val = await redis.get(key);
return val != null;
}
Strictly speaking this is redundant: the claim already returns false on a duplicate. The read exists so the job can distinguish "already notified" from "lost the race to claim", and those two push different counters.
It is also a note about atomicity: the read and the later claim are two round trips, not one transaction, so another replica can claim between them - which is fine, because the claim arbitrates.
syncSubscription: pull the truth the webhook owed you
# lib/billing/creem-adapter.ts — source lines 83–95 (syncSubscription)
async syncSubscription(externalSubscriptionId: string): Promise<SubscriptionStatus> {
const data = await creemFetch(`/subscriptions/${externalSubscriptionId}`);
return {
id: String(data.id ?? ""),
active: data.status === "active",
plan: resolvePlan(String(data.product_id ?? "")),
currentPeriodEnd:
typeof data.current_period_end === "string" ||
typeof data.current_period_end === "number"
? new Date(data.current_period_end)
: null,
};
}
Webhook delivery is at-least-once, which is a polite way of saying it is not guaranteed. This
call is the repair path: given an external subscription id, fetch the provider's record and map
it onto the local shape - an id, an active boolean, a plan resolved from the product id, and a
period end.
Only the exact status active becomes true; every transitional state reads as not active, so
entitlement fails closed - the right default for billing.
cancelSubscription: one call, no local state
# lib/billing/creem-adapter.ts — source lines 97–99 (cancelSubscription)
async cancelSubscription(externalSubscriptionId: string): Promise<void> {
await creemFetch(`/subscriptions/${externalSubscriptionId}/cancel`, { method: "POST" });
}
Three lines, and the design is in what is missing: the adapter does not mark the local
subscription cancelled. It asks the provider to cancel and returns, and the local row changes
when the subscription.canceled webhook lands, which leaves the webhook handler as the single
writer of subscription status.
So an automation that cancels and then reads local state in the same request still shows the old plan. That is a feature if the provider is your source of truth and a bug if it is not. It is also why the cancel path is retry-safe: there is no local write to duplicate.
How SmartGate compares
Every row answers one question: when an action must happen exactly once, who keeps the memory.
| How an action starts | Duplicates | Metered where | What you maintain | |
|---|---|---|---|---|
| Zapier- and Make-style iPaaS | Hosted triggers and connectors | Vendor retries; a duplicate can still reach your action | In the vendor's counters | Nothing, until you need behaviour the connector hides |
| n8n-style self-hosted | Your triggers, your runtime | Whatever the author writes | Your own instrumentation | The runtime, the queue, the upgrades |
| Hand-rolled cron and queue | Your jobs, as quoted here | Keys and claims you design: a 24-hour dedup window, a monthly per-threshold claim | The job's result object | Everything - queue, locks, reconciliation, alerting |
| Agent gateway (SmartGate) | MCP tool calls from agents and from your jobs | Per-call accounting plus a hard budget ceiling | In the gateway, per call, with an audit trail | Only your workflow logic |
| Do nothing | Manual runs and hope | None | Nowhere | The incident review after the duplicate charge |
Rows three and four are complementary rather than competing: the queue and the intake are what a product does for its own customers, and the gateway governs the agent traffic those workflows generate. The cost side of that gateway — quota counters, cached budget checks and the savings arithmetic — is worked through in Build a Low-Cost AI Backend Architecture, and the same per-call records are where ai agent observability begins. SmartGate's billing sentence Shaping the calls themselves so fewer tokens are sent is the other lever, collected in token optimization techniques. SmartGate's billing sentence is the one to test against your own bill - pay for the platform, share only when you save: free is $0 with 2 million tokens a month, Pro starts at $18/mo, capped around $36/mo, Teams starts at $55/mo with a 100-million-token pool capped around $100/mo, and Enterprise is a contract pool through sales (pricing page).
How to get started
- Find the actions that can run twice. For every write your product performs in response to an external event, write down what happens on a duplicate delivery.
- Put the send behind one function, so retry and deduplication are properties of the queue instead of the call site.
- Verify, deduplicate, dispatch - in that order.
- Claim before you send, with a TTL that matches the window the message is about.
- Add a reconciliation call for every webhook you depend on, so a missed event is recoverable.
- Meter the agent side too: a budget ceiling in front of the model calls turns a runaway loop into a refused request.
Start on the free tier - 2 million tokens a month, all seven smart_* tools (smart_fetch,
smart_search, smart_context_gate, smart_dedup, smart_budget_guard, smart_memory,
smart_pipe), no card required:
start free. The tool surface is
documented in the docs, and
talk to sales for contract token pools on Enterprise.
Frequently Asked Questions
Is a queue necessary, or is a fire-and-forget send fine?
For a password reset, fire-and-forget is a support ticket waiting to happen, because the send must survive a provider outage. For an internal digest, a queue is overhead.
Why a 24-hour window for webhook deduplication?
It is longer than any provider retry schedule we know of. Shorter would let a late retry through; longer costs memory.
Why claim a notification instead of checking whether it was sent?
A check followed by a send is a race with two workers on either side of it. A claim is atomic, so exactly one worker proceeds.
What happens to a job whose type is not handled?
Nothing, and that is a defect rather than a design. The switch falls through, the function returns that a job was processed, and no record of the payload survives.
Does the polling interval matter for bursty queues?
Only in proportion to your burst. One worker drains 12 jobs a minute; ten drain 120.
Is the gateway a replacement for this code?
No, it is a different layer. The queue and the intake belong to your product; the gateway sits in front of the model calls your agents make, so an agent that triggers the same workflow fifty times is metered rather than discovered on an invoice.
Limitations and what this does not do
- Webhook delivery is at least once, so idempotency is the caller's job. Every candidate for the dedup key can be absent - when the key is falsy the dedup block is skipped entirely - so treat the 24-hour window as a backstop rather than a guarantee (at-least-once delivery).
- There is no dead-letter handling in the quoted functions. An unhandled job type is dropped silently, and a claimed-but-unsent notification is never retried.
- The worker loop is polling. A fixed 5-second interval sets a latency floor and a ceiling of 12 jobs a minute per worker, with no backoff for a queue that is failing continuously.
- Dedup and quota state live in one cache. Redis is a hard dependency of both the webhook dedup and the notification claim.
- The adapter writes no local state, so there is a window in which the product shows stale entitlement.
Sources
- IBM - what workflow automation is: https://www.ibm.com/think/topics/workflow-automation
- Zapier - workflow automation definition and tools: https://zapier.com/blog/workflow-automation/
- Workato - workflow automation overview with examples: https://www.workato.com/the-connector/workflow-automation-guide/
- Atlassian - AI workflow automation: https://www.atlassian.com/agile/project-management/ai-workflow-automation
- Airtable - AI workflow automation, trigger to output: https://www.airtable.com/articles/ai-workflow-automation
- Paddle - verifying webhook signatures: https://developer.paddle.com/webhooks/signature-verification
- Paddle - webhook overview and event delivery: https://developer.paddle.com/webhooks/overview
- Stripe - idempotent requests and idempotency keys: https://docs.stripe.com/api/idempotent_requests
- Stripe - webhook best practices and retries: https://docs.stripe.com/webhooks
- AWS - at-least-once delivery on standard queues: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues-at-least-once-delivery.html
- Redis - SET with NX and EX, the claim primitive: https://redis.io/docs/latest/commands/set/
- BullMQ - blocking worker model as an alternative to polling: https://docs.bullmq.io/
- Creem - billing API documentation: https://docs.creem.io/
- Model Context Protocol - tool definitions for agent workflows: https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- SmartGate - docs, pricing and sales: https://smartgate.network/docs · https://smartgate.network/pricing · https://smartgate.network/contact
Method note
The code in this article is not transcribed. Each block was cut out of the slice body returned
by the SmartGate slice API and re-asserted byte-for-byte as a substring of that body; the first
line inside every fence records the file and the exact source lines. Symbols were pinned by
whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint - 12
of 12 planned sections pinned, no abstentions. Two sections quote windows rather than whole
bodies: processNextEmail is shown above and below its magic-link branch, which embeds a
loopback URL the pipeline's internal-host scan excludes, and handleProviderWebhook is shown as
verification plus dispatch, with the raw-body read between them; processNormalizedEvent and
runQuotaEmailNotifications are also quoted in windows.
The demand figures (ai workflow automation 1,000/mo, difficulty 28;
workflow automation 2,900/mo, difficulty 51) come from the batch-6 Google Ads volume
and Labs difficulty calls in work/batch6/; this project's own seed phrases returned no
advertiser volume, which keywords.txt records plainly.
Slice provenance
| # | SERP keyword | Symbol | File | Source lines | How it was pinned | sha256(12) |
|---|---|---|---|---|---|---|
| 1 | enqueueEmail enqueue email jobs for saas operations automation | enqueueEmail |
lib/jobs/email.send.ts |
9–14 | rule A L2 → slot-proof | d0f847333b5d |
| 2 | processNextEmail process next email job in saas automation | processNextEmail |
lib/jobs/email.send.ts |
16–25, 39–42 | rule A L2 → slot-proof | d5671b266a99 |
| 3 | startEmailWorker start email worker loop for saas automation | startEmailWorker |
lib/jobs/email.send.ts |
47–57 | rule A L2 → slot-proof | 0093bd9a8da5 |
| 4 | handleProviderWebhook handle provider webhook for saas automation | handleProviderWebhook |
lib/webhook/framework.ts |
168–186, 188–202 | rule A L2 → slot-proof | 51e4c08ea501 |
| 5 | processNormalizedEvent process normalized event from a webhook | processNormalizedEvent |
lib/webhook/framework.ts |
18–28, 41–50 | rule A L2 → slot-proof | 0c5c0810a54f |
| 6 | parsePaddleEventId parse paddle event id for idempotent automation | parsePaddleEventId |
lib/webhook/framework.ts |
9–16 | rule A L2 → slot-proof | 6f9ab8eb1a48 |
| 7 | runQuotaEmailNotifications run quota email notifications for saas accounts | runQuotaEmailNotifications |
lib/jobs/quota-email-notifications.ts |
49–65, 135–144 | rule A L2 → slot-proof | 6647487605cc |
| 8 | shouldNotifyQuotaThreshold should notify quota threshold decision | shouldNotifyQuotaThreshold |
lib/jobs/quota-email-keys.ts |
16–26 | rule A L2 → slot-proof | f76bd013b3a0 |
| 9 | markQuotaEmailSent mark quota email sent for dedupe | markQuotaEmailSent |
lib/jobs/quota-email-notifications.ts |
22–32 | rule A L2 → slot-proof | 454a2490dc94 |
| 10 | wasQuotaEmailSent was quota email sent check for automation | wasQuotaEmailSent |
lib/jobs/quota-email-notifications.ts |
34–43 | rule A L2 → slot-proof | 410cb8f3004a |
| 11 | syncSubscription sync subscription state from the provider | syncSubscription |
lib/billing/creem-adapter.ts |
83–95 | rule A L2 → slot-proof | 608956b0836d |
| 12 | cancelSubscription cancel subscription through the provider adapter | cancelSubscription |
lib/billing/creem-adapter.ts |
97–99 | rule A L2 → slot-proof | 309eb2f14d46 |
Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 12 of 12 sections pinned, 0 abstentions, 0 misses.