Paystack
Setup guide for the paystack payments provider. The shared API (initiate_payment,
verify_payment, refund_payment, webhooks, hooks, saved methods) is documented in
Payments.
The paystack provider supports one-time payments (initiate_payment,
verify_payment), refunds (refund_payment), subscriptions on a Paystack
plan, cancelling them, the billing portal (Paystack’s manage link), reading
disputes, saved cards, off-session charges and webhooks, in NGN, GHS, KES,
ZAR, USD, XOF, EGP and RWF (whatever your Paystack account is enabled for).
GET /payments/providers reports billing_portal, dispute_read,
off_session, payment_methods, subscription_cancel and subscriptions.
| Feature | Status |
|---|---|
| One-time payments, verify, refunds | Supported |
Subscriptions (type: "subscription") |
Supported (a Paystack plan code) |
| Cancel subscription | Supported, immediately only (at_period_end: True is NOT_SUPPORTED, 501) |
Billing portal (get_billing_portal) |
Supported (Paystack’s manage link) |
get_dispute, dispute webhooks |
Supported |
submit_dispute_evidence |
Not supported (NOT_SUPPORTED, 501): answer disputes in the Paystack dashboard |
Saved cards (save_payment_method on a one-time payment), charge_off_session |
Supported (cards only) |
save_payment_method on a subscription |
Not supported (NOT_SUPPORTED, 501) |
| Plan change, pause, saving a card without paying, attaching a card token | Not offered by Paystack (NOT_SUPPORTED, 501) |
It needs no extra install: it calls Paystack’s REST API
(https://api.paystack.co, which has no version in its path) directly over
HTTP. Paystack does not version its API, so Kirak follows the API reference
as of September 2026 (the paystack.com/docs pages and Paystack’s OpenAPI
spec, PaystackOSS/openapi, of June 2026); a later change on Paystack’s side
shows up as an unconfirmed or failing webhook, not as a version mismatch.
Why plain HTTP (no Paystack SDK). Paystack’s own Python package,
paystack-sdk, has had no release since 1.0.1 in September 2022, and the few
endpoints Kirak calls are simple REST.
1. Get credentials. In the Paystack Dashboard,
Settings -> API Keys & Webhooks, copy the secret key: sk_test_... in test
mode, sk_live_... in live mode. Paystack uses the same API host for both;
the key decides the mode.
2. Configure the instance and secrets. In kirak.json:
"paystack": { "type": "paystack", "environment": "test", "success_url": "https://your-domain.com/payment/success"}environment is test (the default) or live and must match the key: a
live instance needs an sk_live_ key and a test one an sk_test_ key,
else the instance fails when first used. success_url is sent to Paystack
as the callback_url the buyer returns to (Paystack appends
?reference=<reference>); only an absolute http(s):// URL is sent, so the
module’s relative default /payment/success is not, and the callback URL set
in the dashboard applies then. In the environment:
KIRAK_PAYMENT_PAYSTACK_SECRET_KEY=sk_test_...For an instance with another name the variable is
KIRAK_PAYMENT_<INSTANCE>_SECRET_KEY (see docs/reference/configuration.md).
3. Configure the webhook. In Settings -> API Keys & Webhooks, set the
webhook URL of the mode you use to
https://your-domain.com/payments/webhook/paystack
(/payments/webhook/<instance> for another instance name). Paystack sends every
event to it (there is no per-event subscription); Kirak handles
charge.success, refund.pending, refund.processing, refund.processed,
refund.failed, refund.needs-attention, subscription.create,
invoice.update, invoice.payment_failed, subscription.not_renew,
subscription.disable, charge.dispute.create, charge.dispute.remind and
charge.dispute.resolve, and acknowledges the rest (including
subscription.expiring_cards and invoice.create, which are only logged)
with event = None.
Each delivery’s x-paystack-signature is checked locally: the hex
HMAC-SHA512 of the raw body keyed with the secret key, compared in constant
time. A missing header is rejected with MISSING_WEBHOOK_SIGNATURE (400) and
a mismatch (including a non-ASCII header) with INVALID_WEBHOOK_SIGNATURE
(400). Paystack signs no timestamp, so there is no time window. Paystack
events carry no id: a delivery whose raw body was already handled (same
sha256) returns already_processed: true. A retry whose bytes differ is
handled again; charge.success still completes the row only once, but a
refund without refund_reference can be recorded twice (see Known limits).
Paystack retries a delivery that does not get a 2xx for up to 72 hours.
Optionally, allow only Paystack’s webhook IPs at your proxy: 52.31.139.75,
52.49.173.169 and 52.214.14.220.
4. Use it:
# One-time payment -- send the buyer to data["url"] (Paystack's checkout)result = await kirak.payments.initiate_payment({ "provider": "paystack", "user_id": "42", "amount": 250000, "currency": "NGN", # kobo: NGN 2,500.00 "name": "Pro Plan", "type": "one_time", "payment_email": "buyer@example.com", # required by Paystack})reference = result["data"]["reference"] # kirak-<32 hex>
# On the callback page (?reference=...): read the payment now instead of# waiting for the webhookawait kirak.payments.verify_payment({"provider": "paystack", "payment_id": reference})
# Refund (admin/system): payment_id is the reference; omit amount for a full refundawait kirak.payments.refund_payment({"provider": "paystack", "payment_id": reference, "amount": 50000})
# Subscription -- subscription_plan_id is a Paystack plan code (PLN_...), created# in Paystack first; the plan sets the price. Send the buyer to data["url"].result = await kirak.payments.initiate_payment({ "provider": "paystack", "user_id": "42", "amount": 500000, "currency": "NGN", "name": "Monthly", "type": "subscription", "payment_email": "buyer@example.com", "subscription_plan_id": "PLN_gx2wn530m0i3w3m",})
# Cancel now (Paystack cannot cancel at the period end through its API)await kirak.payments.cancel_subscription({"provider": "paystack", "subscription_id": "SUB_..."})
# Manage link: the customer changes the card or cancels on Paystack's pageresult = await kirak.payments.get_billing_portal({"provider": "paystack", "user_id": "42"})
# Read a dispute (admin/system)await kirak.payments.get_dispute({"provider": "paystack", "dispute_id": 358950})
# Save the card while paying (one-time only), then charge it later (admin/system)await kirak.payments.initiate_payment({ "provider": "paystack", "user_id": "42", "amount": 250000, "currency": "NGN", "name": "Pro Plan", "type": "one_time", "payment_email": "buyer@example.com", "save_payment_method": True, "consent": {"text_version": "2026-09"},})await kirak.payments.charge_off_session({ "user_id": "42", "amount": 150000, "currency": "NGN", "name": "Usage - September", "idempotency_key": "usage-42-2026-09",})How a payment flows. initiate_payment needs payment_email (else
MISSING_PAYMENT_EMAIL, 400, nothing saved). It saves a PENDING
transaction, stores a new reference kirak-<32 random hex> as its
transaction_id, then calls POST /transaction/initialize with email,
amount x quantity in subunits, currency, the reference, the
callback_url (above) and metadata kirak_transaction_id (the
transaction id) and kirak_instance (the instance name). It returns url
(Paystack’s authorization_url), reference, access_code and
transaction_id. Paystack takes no idempotency key and rejects a reused
reference, so every call gets a fresh random reference (never the caller’s
idempotency_key or the row id, which two Kirak databases on one Paystack
account could repeat). If Paystack rejects the call (4xx) the row is marked
FAILED; a timeout, 5xx or 429 (or a reply without authorization_url)
leaves it PENDING and raises, since the transaction may exist.
Webhooks and verify_payment find the transaction by the reference stored on
a row of this instance. The reference is stored before Paystack is called,
so the metadata tag is never needed to find a row, and an event whose
reference no row of this instance holds (another instance, another database
or other software on the same account) is acknowledged, changes nothing and
reports event = None.
charge.successis not trusted on its own: Kirak re-reads the transaction withGET /transaction/verify/{reference}and completes the row only when Paystack sayssuccess(orreversed: paid, then refunded, so the refund can be recorded next) for the same reference, in the row’s currency, foramountxquantity(or, when the account passes Paystack’s fees to the customer, arequested_amountof exactly that and anamountat least that). The row is then markedCOMPLETEDonce (a redelivery reportsevent = None) andpayment_completedis reported withamount(what Paystack charged, in minor units) andcurrency. A fee-inclusive XOF or RWF amount that is not a multiple of 100 subunits has no exact minor-unit value;amountis then the row’samountxquantity. The Paystack transaction id and raw amount are kept inipn_dump.paystack_transaction_idandipn_dump.gateway_amount;transaction_idstays the reference.- If the amount, currency or reference differ, nothing is written and no
event is reported (
event_typecharge.success.unconfirmed); the delivery is acknowledged. - The same applies when the verified
metadatanames another transaction (kirak_transaction_id) or instance (kirak_instance) than the row’s; a missing value is not checked, and extra keys are ignored. This holds for checkouts, subscription first charges and off-session charges, and forverify_payment. Renaming an instance and movingpayment_serviceon old rows to the new name therefore leaves the charges still in flight unconfirmed (their metadata names the old instance): settle them before renaming. - If the verify endpoint says anything else (
failed,abandoned, or still in progress), or the verify call times out, is rate limited or gets a 5xx, nothing is written and the webhook fails (500) so Paystack redelivers it and a later delivery verifies again. payment_completedcan therefore fire for a charge that was already refunded (verify saysreversed); the refund is recorded when the redeliveredrefund.processedarrives. If Paystack stopped retryingrefund.processedbefore the payment was completed (it retries for up to 72 hours), the row endsCOMPLETEDinstead ofREFUNDED: check the refund in the Paystack dashboard.
verify_payment (payment_id = the reference) reads the transaction and
returns transaction_id, reference, paystack_status and status (the
row’s status after the call). A confirmed success (same checks as above)
sets the row PROCESSING; only charge.success completes it. success
that does not match also returns outcome: "not_confirmed". failed
marks the row FAILED (never over a settled row; a later confirmed
charge.success still completes it). Anything else changes nothing,
including abandoned, which Paystack most likely reports for a
transaction the buyer has not paid yet. TRANSACTION_NOT_FOUND (404) for
a reference this instance did not create; an unknown outcome raises and
changes nothing.
Refunds. refund_payment (payment_id = the reference) calls
POST /refund for the whole transaction, or amount (minor units of the
transaction’s own currency; a currency passed with it is used only when
the transaction has none), and
returns refund_id (Paystack’s numeric refund id),
status (pending) and transaction_id; nothing is recorded yet. Refund
webhooks carry no refund id, so the refunds recorded on the row are keyed
by Paystack’s refund_reference instead, not by this refund_id. Paystack
takes no idempotency key for refunds: after a timeout, check the
transaction’s refunds in Paystack before retrying.
refund.pendingandrefund.processingreportrefund_pendingand change nothing.refund.processedrecords the refund (amount converted from Paystack’s subunits; Paystack sends it as a JSON string or a number; an XOF or RWF amount off the x100 grid is rounded half up, the raw value kept inipn_dump.refund_gateway_amount) and marks the rowREFUNDED, orPARTIALLY_REFUNDEDwhile the refunds recorded so far are belowamountxquantity(aREFUNDEDrow never moves back), and reportsrefund_completedwithamountandcurrency. One that arrives before the payment’scharge.successwas handled fails (500) so Paystack redelivers it after completion. One without anamountfails (500).refund.failed(the money went back to your balance) andrefund.needs-attention(Paystack needs the customer’s bank details) change no status and reportevent = None; they are logged and noted in the row’sipn_dump.refund_issue.
How a subscription flows. initiate_payment with type: "subscription" needs subscription_plan_id, a Paystack plan code (else
MISSING_SUBSCRIPTION_PLAN_ID, 400, nothing saved). It works like a
one-time payment (same reference, same url), with plan added to the
initialize call; Paystack charges the plan’s price instead of amount. The
plan code is kept in the row’s payment_meta.subscription_plan_id.
Paystack creates the subscription when that first charge succeeds, but none
of its events names both: charge.success has the reference but no
subscription code, and subscription.create has the code but no reference.
Kirak therefore links them by customer, plan and card:
charge.successfor a subscription row is confirmed by verify like a one-time payment, except that the check is the plan (the verified transaction must be on the row’s plan code) instead of the amount. Kirak then lists the customer’s subscriptions to that plan (GET /subscription?customer=<id>&plan=<id>) and keeps those that areactive, not yet held by a transaction of this instance (nor saved as asubscriptionsrow for another user by a checkout not completed yet), on the charge’s card (the sameauthorization.authorization_code, or the same cardsignaturewhen the charge has no code) and not created before the charge’s transaction (the subscription’screatedAtagainst the verified transaction’screatedAt, both Paystack times; skipped when either is missing; the row’screated_atis never used, since the database writes it in its own time zone). This keeps an older subscription of the same customer (made outside Kirak, by another database on the account, or left unbound) from being taken for the new one. If exactly one is left, it is bound: asubscriptionsrow is saved (statusACTIVE,plan_id= the plan code, the row’samountandcurrency,expires_on= itsnext_payment_date) and its code becomes the row’ssubscription_id. The row is markedCOMPLETEDwithamount= the verifiedrequested_amountwhen it equals the plan’s amount (fees passed to the customer excluded, as for a one-time payment), else the chargedamount, andquantity1;payment_completedcarries the chargedamountand, when one was bound,subscription_id. The Paystack customer, plan, card (authorization_code,authorization_signature) and transaction time (paystack_created_at) are kept inipn_dump. If the list call times out, is rate limited or gets a 5xx, nothing is written and the webhook fails (500) so Paystack redelivers it. If another delivery of the same charge completed the row first, the subscription found is still set on it, but only while it holds no subscription.- If the list has no such subscription yet, the payment still completes,
unbound;
subscription.createbinds it later: it looks for this instance’s oneCOMPLETED, unbound subscription transaction with the event’s email (in any letter case), customer code, plan code and card, charged before the subscription was created, runs the same list call, and binds only if the event’s subscription is the one it returns. It then reportssubscription_updated. If the only matching transactions (email and plan) are stillPENDINGorPROCESSINGand were created in the last 48 hours (the first charge’scharge.successhas not been handled yet), the webhook fails (500) so Paystack redelivers it after the payment completes; older ones are abandoned checkouts and do not block. An event for a subscription already bound, or one it cannot bind, reportsevent = None. - Which event reports the binding depends on the path: bound at
charge.success, it is reported only bypayment_completed(withsubscription_id), and the latersubscription.createreportsevent = None; bound atsubscription.create, it is reported assubscription_updated. - Ambiguity is never guessed. Two subscriptions that still qualify (two checkouts in a row on the same card), or two unbound transactions that match one event, bind nothing: the case is logged and the subscription stays unbound (see Known limits).
- Renewals come from
invoice.update(Paystack’s final invoice status), which names the subscription and the charge’s reference. The subscription is found by itssubscriptionsrow of this instance, as for the status events below. A paid invoice (paid: true,status: success) is re-read withGET /transaction/verify/{reference}; when Paystack sayssuccess(orreversed) for that reference, thesubscriptionsrow gets theamount(the verifiedrequested_amountwhen it equals the plan’s amount, fees passed to the customer excluded, as for the first charge; else the charged amount), the invoice’snext_payment_dateasexpires_on(only when later than the stored one, since invoices can arrive out of order), andACTIVEagain if it wasPAST_DUEorNON_RENEWING(a charge means it was re-enabled); then asubscription_renewaltransaction is saved with that reference as itstransaction_id, reportingsubscription_renewedwithamountandcurrency. A fee-inclusive XOF or RWF amount off the x100 grid is recorded as the subscription’s amount, the raw value kept inipn_dump.gateway_amount. An unpaid invoice, or a verified reference that differs, records nothing and reportsevent = None; any other verify status, or an unknown outcome, fails the webhook (500) so Paystack redelivers it. The renewal’s owncharge.success(a reference Paystack generated) is acknowledged without a write beforeinvoice.updateis handled (logged at info level, as is any other database’s renewal charge on the account: no lookup is made), and is a repeat afterwards, so a renewal is recorded exactly once. invoice.payment_failedsets the subscriptionPAST_DUE(subscription_past_due); Paystack does not retry the charge, the next cycle does.subscription.not_renew(cancelled at the period end, for example on the manage page) setsNON_RENEWING(subscription_updated), also when the event carries no status.subscription.disablesetsCANCELLED, orCOMPLETEwhen all invoices were billed (subscription_cancelled). An ended subscription is never changed again, and an event that changes nothing reportsevent = None. Renewal and status events for a subscription with nosubscriptionsrow of this instance (not bound here) are acknowledged without a write.subscription.expiring_cardsandinvoice.createare only logged.
Lifecycle. cancel_subscription reads the subscription
(GET /subscription/{code}) for its email_token and calls
POST /subscription/disable with code and token; it cancels
immediately and returns status CANCELLED (the subscriptions row
changes when subscription.disable arrives). Paystack’s API has no cancel
at the period end, so at_period_end: True returns NOT_SUPPORTED (501);
the manage page (below) offers that to the customer. Paystack has no plan
change or pause: update_subscription, pause_subscription and
resume_subscription return NOT_SUPPORTED (501). To move a customer to
another plan, start a new subscription and cancel the old one.
Billing portal. get_billing_portal (user_id) returns the manage
link (GET /subscription/{code}/manage/link) of the user’s newest
subscription of this instance that has not ended, as portal_url, with its
subscription_id; NO_ACTIVE_SUBSCRIPTION (404) when there is none. On
that Paystack page the customer changes the card (Paystack makes a small
refunded charge to check it) or cancels, which sends
subscription.not_renew now and subscription.disable on the next payment
date. return_url is not used.
Disputes (read-only). get_dispute reads GET /dispute/{id} and
returns dispute_id, status and the full dispute.
submit_dispute_evidence is not supported (501): answer disputes in the
Paystack dashboard. charge.dispute.create reports dispute_created;
charge.dispute.remind and charge.dispute.resolve report
dispute_updated. The dispute is matched by its transaction.reference
to a transaction of this instance (a payment or a renewal); the result
carries dispute_id, dispute_status, resolution, transaction_id and
user_id, and amount/currency from the dispute’s refund_amount in its
own currency (converted like a refund) when both are present. The
transaction row is not changed (a non-settled DISPUTED
status would let a late completion event rewrite it). A dispute on a
transaction this instance did not create is acknowledged with
event = None.
Saved cards. Paystack saves no card without a charge, so a card is saved
only while paying: initiate_payment with save_payment_method (and
consent) on a one-time payment keeps the flag and consent in the row’s
payment_meta. When that payment’s charge.success is confirmed by verify
(as above), and before the row is marked COMPLETED, the verified
authorization is saved when Paystack reports it reusable (card payments
only; otherwise nothing is saved and the payment still completes): its
authorization_code is the method’s gateway id, the customer code its
gateway customer, with brand, last4, expiry, the consent, and in meta the
card signature and the customer’s email. The email is kept because only
the email an authorization was created with can charge it. Paystack can
issue a new authorization_code per charge on the same card, so the newest
code is kept: the user’s older active methods of this instance with the
same signature are marked REVOKED locally (not deactivated at Paystack),
and the new one becomes the default (before they are revoked) if one of
them was. A save that fails fails the webhook (500) before the row is
completed, so the redelivery saves it; a row already settled is never saved
again (a late redelivery cannot bring back a removed card). Saving reports
no event of its own: the payment reports payment_completed as usual.
save_payment_method on a subscription is NOT_SUPPORTED (501): the saved
authorization would be the one the subscription renews on, so removing the
card would stop its renewals. start_payment_method_setup and
attach_payment_method are NOT_SUPPORTED (501).
detach_payment_method calls POST /customer/authorization/deactivate
with the authorization_code; a 404 (Paystack no longer knows it) is
treated as removed, so the row is still marked REVOKED; any other error
raises and keeps the row. A card whose authorization_code started one of
the user’s subscriptions of this instance that has not ended (any status but
CANCELLED, COMPLETE or COMPLETED) is only marked REVOKED locally,
without the deactivate call (logged): Paystack may renew that subscription on
the same code. set_default_payment_method is local only (Paystack has no
default card).
Off-session charges. charge_off_session on a Paystack card saves the
PENDING off_session transaction, stores a new random reference
kirak-<32 hex> as its transaction_id, then calls
POST /transaction/charge_authorization with the authorization_code, the
saved email, amount in subunits, currency, that reference and
metadata (stringified JSON naming the transaction and instance). The
reference is never derived from the idempotency_key, which another Kirak
database on the same Paystack account could repeat; a retry with the same
key is replayed from the row without calling Paystack. A card saved without
an email is refused with MISSING_PAYMENT_EMAIL (400) before anything is
saved. A reply naming another reference than the one sent is logged as a
warning. The outcome:
success: the row staysPROCESSINGand the call returnsPROCESSING(COMPLETEDonly whencharge.successcompleted the row first); thecharge.successfor the reference completes the row exactly once after verify confirms it, as for a checkout, and reportspayment_completed. Fulfil on that event, not on the call’s answer.pausedwith anauthorization_url(the bank wants the customer to authorize the charge):REQUIRES_ACTION, andaction_urlis Paystack’sauthorization_urlfor this very charge (no recovery checkout is made); a replay returns it too. Nopayment_action_requiredevent is reported. When the customer authorizes it,charge.successcompletes the row.failed(HTTP 200) or a 400 from Paystack:FAILED, withdecline_code= Paystack’sgateway_responseor error message. Another 4xx marks the rowFAILEDand raises.pendingor any other status:PROCESSING.- A timeout, 5xx, 429, a reply without data, or a 400
duplicate_reference(a transaction with the reference exists):PROCESSINGwithipn_dump.outcome"unknown", neverFAILED. Paystack sends no webhook for a failed charge: callverify_paymentwith the reference (gateway_payment_id) to mark itFAILED.
Currencies. Paystack amounts are integers in subunits = base amount x 100
for every currency. For XOF and RWF, which have no decimals in ISO 4217,
Kirak multiplies by 100 on the way out and divides on the way back: pass
"amount": 1500, "currency": "XOF" for 1,500 XOF (sent as 150000).
Known limits.
- Refund webhooks carry no refund id. Kirak tells a repeat from a second
partial refund by Paystack’s
refund_reference; when Paystack sends none, a redelivery whose body differs byte for byte from the first is recorded as a second refund:refund_completedfires twice, and the doubled total can wrongly mark a partially refunded rowREFUNDED. - A reference is at most 38 characters; Paystack does not document a maximum length.
- A subscription is bound to its first charge only through the customer +
plan list filtered by card and creation time (above). When that is
ambiguous, or Paystack reports the subscription on another authorization
than the first charge’s, nothing is bound: the payment is
COMPLETED, but nosubscriptionsrow exists, and that subscription’s renewals, status events and disputes on renewals are acknowledged without a write. Cancel it in the Paystack dashboard, or have the customer subscribe once. - A
subscription.createwhose email and plan match a first charge of this instance created in the last 48 hours that never completes (an abandoned checkout) fails until that row is 48 hours old or Paystack stops retrying. - Renewals are recorded only from
invoice.update. If Paystack does not send a paidinvoice.updatefor a renewal, the renewal is not recorded: itscharge.successis only logged (at info level, for any charge on a plan whose reference no row of this instance holds). - The same user completing two subscription checkouts at once on the same card and plan can have one subscription bound to both of their own rows.
cancel_subscriptionalways cancels immediately; cancelling at the period end is only possible on Paystack’s manage page.- Disputes are read-only; a dispute event that redelivers with different bytes is reported again.
- A card can be saved only while paying (no setup without a charge, 501). Only the email a card was saved with can charge it; a customer who changes email keeps the card chargeable only under the old one.
- Removing a saved card deactivates that authorization at Paystack; any
other use of the same
authorization_codeoutside Kirak stops working too. - Two saves of the same card at the same instant under different authorization codes can both be kept.
- An older authorization code replaced by a newer one for the same card is revoked only in Kirak; it stays chargeable at Paystack.
- A card that started a running subscription is not deactivated at Paystack when removed; it stays chargeable there until the subscription ends.
Webhook events
Section titled “Webhook events”after_webhook receives these provider-neutral event names (see
Webhook Hooks):
Paystack reports payment_completed (charge.success, once Paystack’s verify endpoint confirms it, including a subscription’s first charge and a charge_off_session charge; an unconfirmed one reports none, with event_type charge.success.unconfirmed), refund_pending (refund.pending, refund.processing), refund_completed (refund.processed), subscription_renewed (a paid invoice.update, once verify confirms its charge; an unpaid or unconfirmed one reports none, with event_type invoice.update.unpaid or invoice.update.unconfirmed), subscription_updated (subscription.create that binds a subscription, subscription.not_renew), subscription_past_due (invoice.payment_failed), subscription_cancelled (subscription.disable), dispute_created (charge.dispute.create) and dispute_updated (charge.dispute.remind, charge.dispute.resolve). A subscription bound when its first charge.success is handled is reported only by that payment_completed (which carries subscription_id), with no subscription_updated; one bound later by subscription.create is reported as subscription_updated.