You set this up in your white-label admin panel, and every URL is on your own domain. In the examples,
app.yourbrand.com stands for that domain. It has nothing to do with the Partner API or its pk_ key: these URLs are authenticated with each offer’s own secret.How it works
- A customer buys on your checkout.
- Your checkout posts the purchase to a webhook URL on your domain. It is stored against the e-mail address they bought with.
- They sign up on your sign-up page with that same address. The plan is applied to their new workspace straight away.
- They verify their e-mail and sign in. No upgrade screen, no checkout, no second payment.
Set it up
1
Create the plan
In your admin panel, open Plans and create the plan buyers should land on, for example “Lifetime Access”. Set the limits you want them to have.Set Display Status to hidden. The plan then never shows on the in-app plans page, so nobody can subscribe to it through the built-in checkout.
2
Create the offer
Open External Checkout and click Add offer. Make one offer per product you sell.
Click Create offer. The offer then shows its Purchase URL, its Refund URL and its Secret, each with a Copy button.
New, next to the secret, makes a new one. The old secret stops working at once, so paste the new one into your checkout straight away.
3
Turn it on
Under Settings on the same page:
Click Save settings.
4
Connect your checkout
Send a
POST to the offer’s purchase URL when someone pays, and to its refund URL when you refund them. Follow the tab for your checkout.- GoHighLevel
- SureCart via OttoKit
- Other checkouts
In the workflow that runs on your order or payment trigger, add a Custom Webhook action:The e-mail is the only field we truly need. Send the order id too: it ties a later refund to this exact purchase, and it stops a retried webhook from counting twice.Repeat on the Refund trigger, pointing at the offer’s refund URL with the same header and body.GoHighLevel’s refund trigger only fires for refunds issued inside GoHighLevel, and it has no trigger for disputes. To catch those too, see Chargebacks and refunds in Stripe.
- Method:
POST - URL: the offer’s purchase URL
- Header:
X-OS-Secret, holding the offer’s secret - Body:
Stripe cannot add the
X-OS-Secret header, so it has its own URL. See Selling through Stripe.The URLs
The URLs accept up to 120 requests a minute from each sending address.
200
status is one of pending, applied, revoked or unmatched, as in Day to day. note says why, when there is something to say.
Prove the request is yours
Every request except Stripe’s must carry the offer’s secret, in one of two ways:X-OS-Secret: the secret itself. The simplest, and what most checkouts can do.X-OS-Signature: a lowercase hex HMAC-SHA256 of the raw request body, keyed with the secret. The secret then never travels with the request.
X-OS-Secret is checked.
product_id or price_id.
What we read from the body
The body is read leniently, so a stock webhook that sends every contact field also works. JSON is best; a form post is read too. For each field we look for any of its usual names, at any depth, ignoring case.- A key at the top of the body beats the same key deeper down, and a name earlier in the list beats a later one.
- The name is read only from the top of the body, or from beside the e-mail address, so a product’s name is never taken for the buyer’s.
- With none of the e-mail names present, the first value anywhere in the body that looks like an e-mail address is used. Send the buyer’s address explicitly to be sure.
- A body with no e-mail address at all is stored as unmatched rather than dropped, so you can see it in the panel.
What the body says it is
An event field can change what a request means:
On the refund URL, a request is always a refund, or a chargeback if the body says so. On the purchase URL and the shared URL the body decides, so a field like
"status": "cancelled" turns a request into a refund. On the shared URL, that is how you send a refund: add "event": "refund".
Subscriptions
External checkout grants access per purchase. It knows nothing about billing periods. To sell a monthly or yearly plan, let your checkout say when access starts and when it stops:- Access on: send a purchase when a subscription starts, or when it is reactivated. Not on every renewal.
- Access off: send a refund when the subscription is cancelled, or when its payment has finally failed.
One id per activation
Use one stable id for each activation asorder_id, and send that same id to the refund URL when it ends. That is how the cancellation finds the right grant.
Cancel on the last failure, not the first
Most checkouts retry a failed renewal for several days before they give up. Take access away when the checkout gives up and ends the subscription, not on the first failed payment. Otherwise a client whose card is declined once loses access, and when the retry succeeds nothing gives it back, because a renewal is not a purchase. The same goes for a client who cancels at the end of a paid period: use the event for when the subscription actually ends, not the one for when they ask to cancel.Offer settings for a subscription
- Expires after: leave it empty. Your webhooks decide when access ends.
- Take access back on refund: on. A cancellation arrives as a refund; with this off, it is stored as unmatched and changes nothing.
- On refund or chargeback, in Settings: choose Move the workspace to the free plan, and keep an active free plan. A locked workspace stays locked when the client reactivates (unless the offer is Unrestricted), and you would have to unlock it by hand: edit the workspace in the admin panel and set Access status to Requires subscription.
Example: SureCart through OttoKit
1
Create the offer
Add an offer with Sold on set to Something else, the plan to grant, and the settings above. Copy its purchase URL, refund URL and secret.
2
Access on: a new subscription
In OttoKit, create an automation that starts on SureCart’s event for a new subscription or purchase. Trigger names vary between versions, so pick the one that fires once, when the client first pays, and not on each renewal.Add an action that sends a webhook (an HTTP
POST request):- URL: the offer’s purchase URL
- Header:
X-OS-Secret, holding the offer’s secret - JSON body, with each value filled from the trigger’s data:
3
Access off: cancelled, or payment finally failed
Create a second automation on SureCart’s event for a subscription that is cancelled or ends, or a purchase that is revoked: whichever fires when access should end, after payment retries are over.Use the same action, header and body, pointed at the offer’s refund URL, with
order_id set to the id this activation started with. If the trigger cannot give you that id, use the subscription id plus the cancellation date and time, as in the tip above.4
Access back on: reactivated
Create a third automation on SureCart’s event for a subscription that is reactivated or restored. Post to the offer’s purchase URL, with
order_id set to the subscription id plus the reactivation date, so it is new.5
Test the cycle
With a test subscription, start, cancel and reactivate it once each. After each step, open Purchases in the admin panel: you should see a purchase applied, then revoked, then a new purchase applied.
Selling through Stripe
Stripe cannot send theX-OS-Secret header, so a Stripe sale never goes to an offer’s purchase URL. Stripe’s own webhook reports it, signed with the endpoint’s signing secret.
This covers one-off payments. Subscription checkouts are ignored here, which also keeps the built-in billing’s own checkouts on the same Stripe account from being read as purchases. For recurring plans through Stripe, use the built-in Stripe billing.
- Create the plan and turn the feature on, as above.
- In Stripe, create a Payment Link for a one-time price.
- Add an offer with Sold on set to Stripe, and put the Payment Link’s ID in Product id. The ID starts with
plink_and shows in the address bar when you open the Payment Link in Stripe. - In Stripe, add a webhook endpoint at
https://<your domain>/api/external-checkout/stripewith these events:checkout.session.completedcheckout.session.async_payment_succeededcharge.refundedcharge.dispute.createdcharge.dispute.closed
- Paste the endpoint’s signing secret into Stripe signing secret in Settings, and save.
- A paid checkout records the purchase against the e-mail address the buyer entered in Stripe, and grants it like any other purchase.
- Bank debits and other delayed methods complete the checkout before the money arrives. The grant waits for
checkout.session.async_payment_succeeded, so a payment that fails never grants anything. - A refund or dispute finds the sale through its payment, with the rules below.
- A Payment Link no offer knows is still recorded, as unmatched, with the link’s ID in the note. Add that ID to an offer, then grant the buyer by hand.
client_reference_id to the offer’s token, shown on the offer in the panel. On a Payment Link it is ignored, because a buyer can set it in the link’s URL.
Chargebacks and refunds in Stripe
Even if you sell on GoHighLevel or another checkout, Stripe’s webhook is the way to catch a chargeback, or a refund someone issued from the Stripe dashboard:- URL:
https://<your domain>/api/external-checkout/stripe - Events:
charge.refunded,charge.dispute.created,charge.dispute.closed - Paste the signing secret Stripe gives you into Stripe signing secret in Settings.
Day to day
External Checkout → Purchases lists everything that arrived and what we did with it. Search by e-mail address or order id, or filter by status.- Grant access by hand, at the top of the page, is for a buyer who signed up with a different e-mail address, or bought somewhere we get no webhook. Pick the offer, enter the address and click Grant access. It behaves exactly like a webhook purchase.
- Apply tries a pending or unmatched purchase again, for example once the buyer has a workspace, or after you fixed the offer’s plan. A purchase for a product no offer knew cannot be applied: use Grant access by hand for it.
- Revoke, on any applied purchase, takes the access back. The workspace moves to your free plan, or is locked, as set in On refund or chargeback. If another applied purchase still covers the workspace, it keeps its plan.
Can someone get a free ride?
No. Finding your sign-up page does nothing for them: signing up puts every new workspace on your ordinary free plan. A paid plan is only ever attached from a purchase record, and a purchase record is only ever created by a request carrying an offer’s secret, by a Stripe event signed with your Stripe signing secret, or by an admin acting on purpose.Test it end to end
With the feature turned on and an offer created:"status": "pending", or "applied" if that address already has an account.
- Open Purchases. The row reads pending.
- Sign up on your sign-up page with
tester@example.com. The row changes to applied. - Sign in. You land on the dashboard, on the granted plan, with no upgrade screen. With Hide in-app billing on, there is no Billing item in the workspace menu.
- Send the same request again. You get the same
purchase_idback and nothing changes: the order id makes it a duplicate. - Now the refund:
4242 4242 4242 4242, then refund the payment from the Stripe dashboard. The purchase reads applied or pending, then revoked.