[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M70FFBWNPT7SH1PT13C3RRN4":3,"skill-same:01M70FFBWNPT7SH1PT13C3RRN4":47},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":21,"linkOnly":22,"markdownContent":23,"markdownBody":24,"frontMatter":25,"refName":26,"commitSha":27,"skillDirectory":28,"forksCount":14,"repositoryHtmlUrl":29,"repositoryHomepageUrl":6,"ownerHtmlUrl":30,"ownerBlogUrl":31,"ownerDescription":6,"updatedAt":32,"install":33,"resources":46},"01M70FFBWNPT7SH1PT13C3RRN4","webhook",null,"Handle Revolut webhook events","isap-ou\u002Flaravel-cashier-revolut","isap-ou","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F156415959?v=4","01M70FF3WW9TE0F7QJ3C2NSNA6","ISAPP","01M70FF3XGRYPYBFQFVX1SHFYR",0,"MIT","https:\u002F\u002Fgithub.com\u002Fisap-ou\u002Flaravel-cashier-revolut\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fwebhook\u002FSKILL.md",[],[5,19,20],"revolut","events","2026-11-10 10:12:30",false,"---\ndescription: Handle Revolut webhook events\n---\n\n# Webhook Handling\n\n**This package ships no controller and no route.** support#47 took the entry point\n(`webhook\u002Fcashier\u002F{provider}`) and — the point of the move — the ORDER of the steps around it.\nGetting that order wrong was #24, and it hid 14 event types behind a 200 for the life of the\npackage.\n\nThe flow, and who owns each part:\n\n1. **support's `WebhookController`** receives the POST and calls the driver's one webhook method,\n   `RevolutGateway::webhook($content, $headers)` (`Concerns\u002FHandlesRevolutWebhooks.php`), which\n   hands back a `RevolutIncomingWebhook`.\n2. **`RevolutIncomingWebhook::parse()`** — verifies the HMAC-SHA256 signature over\n   `v1.{timestamp}.{body}` (`Webhooks\u002FRevolutWebhookVerifier.php`), then decodes. A missing signing\n   secret is a hard failure, not a shrug. A body that is not a JSON object throws.\n3. **support dispatches `Events\\WebhookReceived`** with the raw decoded body — above any decision\n   about what the event means. This is the escape hatch, and it is support's to sequence.\n4. **`RevolutIncomingWebhook::pipeline()`** → `RevolutWebhookSynchronizer::handle()` applies the\n   event and returns `bool`.\n5. **support dispatches `Events\\WebhookHandled`** only when that bool is `true`.\n\n## The two sets, which are not the same set\n\n- **Subscribed** — `config('cashier-revolut.webhook.events')`, defaulting to all 22 cases of\n  `Enums\\RevolutWebhookEvent`. This is what `RevolutGateway::registerWebhook()` tells Revolut to\n  send. An event not in this list is never *delivered*, so it reaches no listener at all.\n- **Applied** — the `match` in `RevolutWebhookSynchronizer::handle()`, 8 of the 22. Everything\n  else takes `default => $applied = false`.\n\nMatching an arm is **necessary, not sufficient** (#35). A mapped event can still find nothing to\napply itself to, and then it is as unhandled as the 14: a refund\u002Fchargeback order, an owner that\nresolves to nobody, a `SUBSCRIPTION_*` with no local record, a 404 on the refetch. `syncOrder()`\nand `syncSubscription()` return `bool` and each arm assigns from them — so the *candidate* set is\nthe match, and the *applied* set is what those methods answer. Do not latch `true` before the\nmatch again; that was the bug.\n\nThe rule they implement is \"the delivery had an effect\": a local row was written, **or** a payment\noutcome was announced. Two edges follow, and both are deliberate — an unchanged subscription\nstatus is `true` (the row was mirrored before the typed event was skipped), and a declined payment\nis `true` (nothing persisted, but `PaymentFailed` was dispatched).\n\n`Enums\\RevolutWebhookEvent` is the **catalogue** — a fact about Revolut. It says nothing about our\ncoverage, and must not grow an `isMapped()`. Conflating the two is what made the hatch unreachable.\n\n## Adding an event\n\nTo **deliver** one Revolut adds: add the case to the enum. It is then subscribed by default and\nreaches `WebhookReceived` listeners, with no further work.\n\nTo **apply** one: add an arm to the synchronizer's match that **assigns** from a sync method —\n`=> $applied = $this->syncX($event, $id)` — and make that method `return bool`, `true` only when\nthe delivery had an effect and `false` on every path that returns early having done nothing. An\narm that calls a `void` method drops the answer; one that assigns `true` outright re-creates #35.\nDispatch the support event (`Events\\PaymentSucceeded`, `Events\\SubscriptionCanceled`, …) from\ninside the sync method. Whether an event *deserves* a typed agnostic event is a support question\nfirst (#28) — support leads, drivers follow.\n\n## The one rule\n\n**`pipeline()` returns `false` for an event we do not apply, and never throws.** A throw turns an\nevent we merely have no opinion about into a failed delivery — Revolut retries a 4XX three more\ntimes at 10-minute intervals and then drops it to the failed-events list, so the cost is wasted\nretries and a polluted failure log, not an infinite loop. If making it throw feels more honest,\nthat instinct is #24 — `WebhookSyncTest` and `WebhookDeliveryTest` will stop you.\n","# Webhook Handling\n\n**This package ships no controller and no route.** support#47 took the entry point\n(`webhook\u002Fcashier\u002F{provider}`) and — the point of the move — the ORDER of the steps around it.\nGetting that order wrong was #24, and it hid 14 event types behind a 200 for the life of the\npackage.\n\nThe flow, and who owns each part:\n\n1. **support's `WebhookController`** receives the POST and calls the driver's one webhook method,\n   `RevolutGateway::webhook($content, $headers)` (`Concerns\u002FHandlesRevolutWebhooks.php`), which\n   hands back a `RevolutIncomingWebhook`.\n2. **`RevolutIncomingWebhook::parse()`** — verifies the HMAC-SHA256 signature over\n   `v1.{timestamp}.{body}` (`Webhooks\u002FRevolutWebhookVerifier.php`), then decodes. A missing signing\n   secret is a hard failure, not a shrug. A body that is not a JSON object throws.\n3. **support dispatches `Events\\WebhookReceived`** with the raw decoded body — above any decision\n   about what the event means. This is the escape hatch, and it is support's to sequence.\n4. **`RevolutIncomingWebhook::pipeline()`** → `RevolutWebhookSynchronizer::handle()` applies the\n   event and returns `bool`.\n5. **support dispatches `Events\\WebhookHandled`** only when that bool is `true`.\n\n## The two sets, which are not the same set\n\n- **Subscribed** — `config('cashier-revolut.webhook.events')`, defaulting to all 22 cases of\n  `Enums\\RevolutWebhookEvent`. This is what `RevolutGateway::registerWebhook()` tells Revolut to\n  send. An event not in this list is never *delivered*, so it reaches no listener at all.\n- **Applied** — the `match` in `RevolutWebhookSynchronizer::handle()`, 8 of the 22. Everything\n  else takes `default => $applied = false`.\n\nMatching an arm is **necessary, not sufficient** (#35). A mapped event can still find nothing to\napply itself to, and then it is as unhandled as the 14: a refund\u002Fchargeback order, an owner that\nresolves to nobody, a `SUBSCRIPTION_*` with no local record, a 404 on the refetch. `syncOrder()`\nand `syncSubscription()` return `bool` and each arm assigns from them — so the *candidate* set is\nthe match, and the *applied* set is what those methods answer. Do not latch `true` before the\nmatch again; that was the bug.\n\nThe rule they implement is \"the delivery had an effect\": a local row was written, **or** a payment\noutcome was announced. Two edges follow, and both are deliberate — an unchanged subscription\nstatus is `true` (the row was mirrored before the typed event was skipped), and a declined payment\nis `true` (nothing persisted, but `PaymentFailed` was dispatched).\n\n`Enums\\RevolutWebhookEvent` is the **catalogue** — a fact about Revolut. It says nothing about our\ncoverage, and must not grow an `isMapped()`. Conflating the two is what made the hatch unreachable.\n\n## Adding an event\n\nTo **deliver** one Revolut adds: add the case to the enum. It is then subscribed by default and\nreaches `WebhookReceived` listeners, with no further work.\n\nTo **apply** one: add an arm to the synchronizer's match that **assigns** from a sync method —\n`=> $applied = $this->syncX($event, $id)` — and make that method `return bool`, `true` only when\nthe delivery had an effect and `false` on every path that returns early having done nothing. An\narm that calls a `void` method drops the answer; one that assigns `true` outright re-creates #35.\nDispatch the support event (`Events\\PaymentSucceeded`, `Events\\SubscriptionCanceled`, …) from\ninside the sync method. Whether an event *deserves* a typed agnostic event is a support question\nfirst (#28) — support leads, drivers follow.\n\n## The one rule\n\n**`pipeline()` returns `false` for an event we do not apply, and never throws.** A throw turns an\nevent we merely have no opinion about into a failed delivery — Revolut retries a 4XX three more\ntimes at 10-minute intervals and then drops it to the failed-events list, so the cost is wasted\nretries and a polluted failure log, not an infinite loop. If making it throw feels more honest,\nthat instinct is #24 — `WebhookSyncTest` and `WebhookDeliveryTest` will stop you.\n",{"description":7},"main","fa6952436652f11218a0d1b3c08beb5457ee83ca",".claude\u002Fskills\u002Fwebhook","https:\u002F\u002Fgithub.com\u002Fisap-ou\u002Flaravel-cashier-revolut","https:\u002F\u002Fgithub.com\u002Fisap-ou","https:\u002F\u002Fisapp.be","2026-07-24 06:17:47",{"source":8,"skillName":5,"commands":34,"prompt":44,"zipAvailable":45},[35,38,41],{"tool":36,"command":37},"npx","npx skills add isap-ou\u002Flaravel-cashier-revolut --skill webhook",{"tool":39,"command":40},"bunx","bunx skills add isap-ou\u002Flaravel-cashier-revolut --skill webhook",{"tool":42,"command":43},"pnpm","pnpm dlx skills add isap-ou\u002Flaravel-cashier-revolut --skill webhook","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fisap-ou\u002Flaravel-cashier-revolut\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fwebhook\u002FSKILL.md",true,[],[48,60,71,89,105],{"skillNo":49,"name":50,"nameZh":6,"description":51,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":52,"categories":53,"keywords":54,"publishedAt":21},"01M70FFAQFVPWMN7BRY7XKDYCV","subscriptions","Work with Revolut Subscriptions API — plans, subscriptions, billing cycles","https:\u002F\u002Fgithub.com\u002Fisap-ou\u002Flaravel-cashier-revolut\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fsubscriptions\u002FSKILL.md",[],[50,55,56,57,58,19,59],"plans","with","work","billing","api",{"skillNo":61,"name":62,"nameZh":6,"description":63,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":64,"categories":65,"keywords":66,"publishedAt":21},"01M70FF9WT5HABRX31EAS5XMVA","migration","Create or modify database migrations","https:\u002F\u002Fgithub.com\u002Fisap-ou\u002Flaravel-cashier-revolut\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fmigration\u002FSKILL.md",[],[62,67,68,69,70],"database","migrations","database migrations","create",{"skillNo":72,"name":73,"nameZh":6,"description":74,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":75,"categories":76,"keywords":82,"publishedAt":21},"01M70FF8TEH4V3WAMDRTK0GJVW","implement-contract","Implement a contract (interface) from isapp\u002Flaravel-cashier-support","https:\u002F\u002Fgithub.com\u002Fisap-ou\u002Flaravel-cashier-revolut\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fimplement-contract\u002FSKILL.md",[77],{"categoryNo":78,"categoryName":79,"taxonomyType":80,"taxonomyTypeDesc":81},"01JBSEEDCAT000000000000037","律师与法务","PROFESSION","适用职业",[83,84,85,86,87,88],"implement","implement contract","contract","laravel","support","interface",{"skillNo":90,"name":91,"nameZh":6,"description":92,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":93,"categories":94,"keywords":95,"publishedAt":21},"01M70FF7X6B3ZKD9X7A0ZKG3FX","check","Run quality checks — tests, static analysis, code style","https:\u002F\u002Fgithub.com\u002Fisap-ou\u002Flaravel-cashier-revolut\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fcheck\u002FSKILL.md",[],[91,96,97,98,99,100,101,102,103,104],"code style","quality","code","analysis","static","run","static analysis","style","tests",{"skillNo":106,"name":107,"nameZh":6,"description":108,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":109,"categories":110,"keywords":119,"publishedAt":21},"01M70FF6BT4H51PJE5QKVSJHAE","api-request","Make an HTTP request to the Revolut Merchant API via RevolutClient","https:\u002F\u002Fgithub.com\u002Fisap-ou\u002Flaravel-cashier-revolut\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fapi-request\u002FSKILL.md",[111,116],{"categoryNo":112,"categoryName":113,"taxonomyType":114,"taxonomyTypeDesc":115},"01JBSEEDCAT000000000000010","后端与接口","SCENARIO","使用场景",{"categoryNo":117,"categoryName":118,"taxonomyType":80,"taxonomyTypeDesc":81},"01JBSEEDCAT000000000000030","开发工程师",[120,121,59,122,123,124,125,126,19],"request","api request","make","http request","via","merchant","http"]