@ssheleg/sheleg-dev 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +172 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/SECURITY.md +63 -0
- package/bin/sheleg-dev.js +99 -0
- package/package.json +45 -0
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +28 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +442 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +257 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +201 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +389 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +103 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +288 -0
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +384 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +1357 -0
- package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +147 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/accessibility-perf.md +172 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/css-performance.md +180 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/nextjs.md +271 -0
- package/plugins/sheleg-dev/skills/google-auth/SKILL.md +275 -0
- package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +464 -0
- package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +615 -0
- package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +593 -0
- package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +376 -0
- package/plugins/sheleg-dev/skills/google-signin/SKILL.md +121 -0
- package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +421 -0
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +458 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/price-integrity.md +201 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +173 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +294 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +214 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +211 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Webhook events — the catalogue and the contract
|
|
2
|
+
|
|
3
|
+
**Load this when** writing or reviewing the webhook handler: which events to
|
|
4
|
+
subscribe to, what each payload actually carries, and the ordering and failure
|
|
5
|
+
rules that decide whether a payment becomes an entitlement exactly once.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [The endpoint contract](#the-endpoint-contract)
|
|
10
|
+
- [Which events to subscribe to](#which-events-to-subscribe-to)
|
|
11
|
+
- [checkout.session.completed](#checkoutsessioncompleted)
|
|
12
|
+
- [Asynchronous payment methods](#asynchronous-payment-methods)
|
|
13
|
+
- [invoice.paid](#invoicepaid)
|
|
14
|
+
- [invoice.payment_failed and dunning](#invoicepayment_failed-and-dunning)
|
|
15
|
+
- [customer.subscription.updated](#customersubscriptionupdated)
|
|
16
|
+
- [customer.subscription.deleted](#customersubscriptiondeleted)
|
|
17
|
+
- [charge.refunded and charge.dispute.created](#chargerefunded-and-chargedisputecreated)
|
|
18
|
+
- [Ordering is not guaranteed](#ordering-is-not-guaranteed)
|
|
19
|
+
- [Idempotency store](#idempotency-store)
|
|
20
|
+
- [What to log](#what-to-log)
|
|
21
|
+
|
|
22
|
+
## The endpoint contract
|
|
23
|
+
|
|
24
|
+
| Situation | Status | Why |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| handled successfully | 200 | |
|
|
27
|
+
| duplicate delivery | 200 | a non-2xx makes Stripe retry forever |
|
|
28
|
+
| event type you do not handle | 200 | subscribing to more than you handle is normal |
|
|
29
|
+
| missing or invalid signature | 400 | and no detail in the body — it is an oracle |
|
|
30
|
+
| webhook secret not configured | 500 | a misconfigured endpoint must not look healthy |
|
|
31
|
+
| your idempotency store is down | 503 | you cannot prove this is not a duplicate |
|
|
32
|
+
| handler threw | 500 | after releasing the claim, so the retry can work |
|
|
33
|
+
|
|
34
|
+
Stripe retries with exponential backoff for up to three days, then disables the
|
|
35
|
+
endpoint and emails the account. A handler that answers 200 on failure gets no
|
|
36
|
+
retry and no email — the payment is simply gone from your side.
|
|
37
|
+
|
|
38
|
+
Respond fast. Heavy work belongs in a queue: Stripe times out at 30 seconds and
|
|
39
|
+
counts the timeout as a failure.
|
|
40
|
+
|
|
41
|
+
## Which events to subscribe to
|
|
42
|
+
|
|
43
|
+
A subscription product needs seven; anything else is noise you will have to read
|
|
44
|
+
through during an incident.
|
|
45
|
+
|
|
46
|
+
| Event | Why |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `checkout.session.completed` | the first grant |
|
|
49
|
+
| `checkout.session.async_payment_succeeded` | delayed methods that later clear |
|
|
50
|
+
| `checkout.session.async_payment_failed` | delayed methods that later fail |
|
|
51
|
+
| `invoice.paid` | renewals, and the proration invoices that must *not* grant |
|
|
52
|
+
| `invoice.payment_failed` | dunning starts; mark past due |
|
|
53
|
+
| `customer.subscription.updated` | plan change, quantity change, cancel scheduled, status change |
|
|
54
|
+
| `customer.subscription.deleted` | the teardown, from any source |
|
|
55
|
+
| `charge.refunded` | claw back what was granted |
|
|
56
|
+
|
|
57
|
+
Add `charge.dispute.created` if disputes are a real risk for your business, and
|
|
58
|
+
`customer.subscription.trial_will_end` if you run trials.
|
|
59
|
+
|
|
60
|
+
## checkout.session.completed
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
if (session.payment_status === "unpaid") return; // an async method: wait for the async event
|
|
64
|
+
const userId = session.metadata?.userId; // set at session creation
|
|
65
|
+
if (!userId) { log.error("session without userId"); return; } // 200, but loudly
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- `mode` is `"subscription"` or `"payment"` — branch on it before anything else.
|
|
69
|
+
- The session's `subscription` field is an **id**, not an object, unless you
|
|
70
|
+
expanded it. Retrieve it to read items, quantity and period.
|
|
71
|
+
- Uniqueness key: the subscription id for subscriptions, the payment intent id
|
|
72
|
+
for one-off payments. Put a unique constraint on the column and let the
|
|
73
|
+
database arbitrate — the `SELECT` that precedes the `INSERT` is a race.
|
|
74
|
+
- A user that no longer exists is a 200 with an error log, not a 500. Retrying
|
|
75
|
+
will not bring them back.
|
|
76
|
+
|
|
77
|
+
## Asynchronous payment methods
|
|
78
|
+
|
|
79
|
+
Bank debits, vouchers and bank transfers complete the session before the money
|
|
80
|
+
moves. `payment_status: "unpaid"` at `checkout.session.completed` means the
|
|
81
|
+
grant belongs to `async_payment_succeeded`, which may arrive days later — and
|
|
82
|
+
`async_payment_failed` means it never will. Both must be handled or the product
|
|
83
|
+
is either never granted or granted for a payment that failed.
|
|
84
|
+
|
|
85
|
+
## invoice.paid
|
|
86
|
+
|
|
87
|
+
The single most consequential field is `billing_reason`:
|
|
88
|
+
|
|
89
|
+
| Value | Meaning | Grant? |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `subscription_create` | first invoice for a new subscription | **no** — checkout did it |
|
|
92
|
+
| `subscription_cycle` | the renewal | **yes** |
|
|
93
|
+
| `subscription_update` | mid-cycle proration (quantity or plan change) | **no** |
|
|
94
|
+
| `subscription_threshold` | a usage threshold was crossed | explicitly, if you use them |
|
|
95
|
+
| `manual` | an invoice you created | explicitly |
|
|
96
|
+
|
|
97
|
+
Reading the subscription id off an invoice depends on the API version. On
|
|
98
|
+
current versions it hangs off `invoice.parent.subscription_details.subscription`
|
|
99
|
+
and may be a string or an object:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
function subscriptionIdOf(invoice: Stripe.Invoice): string | null {
|
|
103
|
+
const ref = invoice.parent?.subscription_details?.subscription;
|
|
104
|
+
if (typeof ref === "string") return ref;
|
|
105
|
+
if (ref && typeof ref === "object") return ref.id;
|
|
106
|
+
return null; // not a subscription invoice
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Period dates live on the **subscription item**
|
|
111
|
+
(`sub.items.data[0].current_period_start` / `current_period_end`), not on the
|
|
112
|
+
subscription. Code that reads the old top-level fields gets `undefined` and
|
|
113
|
+
stores an epoch date — no error, wrong renewal date forever.
|
|
114
|
+
|
|
115
|
+
Guard the grant with a marker that survives a replay: a `lastGrantedPeriodStart`
|
|
116
|
+
compared against the new period start, or an audit row keyed by `invoice.id`.
|
|
117
|
+
Check it **inside** the same transaction as the grant.
|
|
118
|
+
|
|
119
|
+
## invoice.payment_failed and dunning
|
|
120
|
+
|
|
121
|
+
Mark the subscription `past_due` and tell the user, with a link to the customer
|
|
122
|
+
portal — this is the highest-value notification in the whole integration.
|
|
123
|
+
|
|
124
|
+
Do not cancel anything here. Stripe's dunning settings decide how many retries
|
|
125
|
+
happen over how many days and what the terminal state is (`canceled` or
|
|
126
|
+
`unpaid`); when it ends you receive `customer.subscription.deleted` or an
|
|
127
|
+
`updated` with the final status. Cancelling early cancels customers whose second
|
|
128
|
+
attempt would have succeeded.
|
|
129
|
+
|
|
130
|
+
## customer.subscription.updated
|
|
131
|
+
|
|
132
|
+
One event covers plan change, quantity change, `cancel_at_period_end` flipping,
|
|
133
|
+
trial ending, and status transitions. Diff against your stored row and act only
|
|
134
|
+
on what actually changed.
|
|
135
|
+
|
|
136
|
+
The `previous_attributes` object on the event tells you what changed — but it is
|
|
137
|
+
on the **event**, not the subscription object, so a handler typed as
|
|
138
|
+
`(sub: Stripe.Subscription)` cannot see it. Pass the event through if you need
|
|
139
|
+
it.
|
|
140
|
+
|
|
141
|
+
For seat and metered products, quantity changes are usually driven by your own
|
|
142
|
+
endpoints, which already wrote the new quantity. Make this handler idempotent
|
|
143
|
+
against its own writes rather than trying to detect "who did this".
|
|
144
|
+
|
|
145
|
+
## customer.subscription.deleted
|
|
146
|
+
|
|
147
|
+
The teardown, and the only place it should live: it fires whether the
|
|
148
|
+
cancellation came from your UI, the customer portal, dunning, or an admin in the
|
|
149
|
+
Dashboard.
|
|
150
|
+
|
|
151
|
+
Order matters:
|
|
152
|
+
|
|
153
|
+
1. external calls that can fail (reclaim balances, disable provisioned
|
|
154
|
+
resources) — outside the transaction, each individually caught;
|
|
155
|
+
2. one transaction: reclaim credit, unlink resources, mark canceled;
|
|
156
|
+
3. after commit: stop containers, send notifications, emit analytics.
|
|
157
|
+
|
|
158
|
+
Doing (3) inside the transaction sends "your subscription ended" for a
|
|
159
|
+
transaction that then rolls back.
|
|
160
|
+
|
|
161
|
+
## charge.refunded and charge.dispute.created
|
|
162
|
+
|
|
163
|
+
`amount_refunded` is **cumulative**. Compute the increment against your stored
|
|
164
|
+
total under a compare-and-swap; see the `SKILL.md` section. Resolve the target
|
|
165
|
+
two ways: by `payment_intent` for one-off purchases, and by `charge.invoice` →
|
|
166
|
+
invoice → subscription for subscription refunds. A refund handler that only
|
|
167
|
+
knows the first path silently ignores every subscription refund.
|
|
168
|
+
|
|
169
|
+
`charge.dispute.created` is money already gone plus a fee. Treat it as a refund
|
|
170
|
+
for entitlement purposes and route it to a human — evidence has a deadline.
|
|
171
|
+
|
|
172
|
+
## Ordering is not guaranteed
|
|
173
|
+
|
|
174
|
+
Events can arrive out of order, and Stripe's own docs say so. Two consequences:
|
|
175
|
+
|
|
176
|
+
- **Never derive state from arrival order.** Derive it from the object in the
|
|
177
|
+
payload, or re-retrieve the object and use that.
|
|
178
|
+
- **Guard on state, not on time.** "Only apply if the stored period is older"
|
|
179
|
+
beats "apply if this event is newer than the last one I saw", because the
|
|
180
|
+
second one trusts a clock you do not own.
|
|
181
|
+
|
|
182
|
+
For a subscription whose state you cannot reconstruct from one event, retrieve
|
|
183
|
+
the subscription fresh inside the handler. One extra API call is cheaper than a
|
|
184
|
+
class of ordering bugs.
|
|
185
|
+
|
|
186
|
+
## Idempotency store
|
|
187
|
+
|
|
188
|
+
```sql
|
|
189
|
+
CREATE TABLE processed_webhook_events (
|
|
190
|
+
id TEXT PRIMARY KEY, -- Stripe's evt_… id
|
|
191
|
+
source TEXT NOT NULL, -- 'stripe' — the table serves every provider
|
|
192
|
+
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
193
|
+
);
|
|
194
|
+
CREATE INDEX ON processed_webhook_events (processed_at);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- `claimEvent` = `INSERT`; unique violation means duplicate.
|
|
198
|
+
- `releaseEventClaim` = `DELETE`, called only when processing threw.
|
|
199
|
+
- Prune older than ~30 days on a schedule. Stripe stops retrying after three
|
|
200
|
+
days, so anything older is dead weight — but do not prune to a window shorter
|
|
201
|
+
than the retry window, or a late retry reprocesses.
|
|
202
|
+
- A failure to *claim* (database down) is a 503, never an optimistic "probably
|
|
203
|
+
new".
|
|
204
|
+
|
|
205
|
+
## What to log
|
|
206
|
+
|
|
207
|
+
Structured, and enough to answer "what happened to this payment" without the
|
|
208
|
+
Dashboard: event id, event type, subscription or payment intent id, the user id
|
|
209
|
+
from metadata, the decision taken ("granted", "skipped: subscription_update",
|
|
210
|
+
"duplicate"), and the outcome. Never the raw payload — it contains customer
|
|
211
|
+
data — and never anything key-shaped.
|