Payments
Paddle payments for GoCommerce
A Go package that creates a Paddle Billing transaction at checkout, sends the shopper to Paddle’s checkout, and marks the GoCommerce order paid when a signed transaction.completed notification arrives. Paddle is the merchant of record, and that changes how you set up tax.
- Payments module
- 6 settings
- 7 tests
- ext/payments-paddle
What it does
The module registers “paddle” as a payment method. At checkout it creates a Paddle transaction with one ad-hoc line — “Order” and the order number, at the order’s total — and answers with a redirect intent carrying the transaction’s checkout URL.
Paddle then calls POST /api/checkout/paddle/webhook. The module verifies the Paddle-Signature header; transaction.completed asks the engine to mark the order paid, and transaction.payment_failed to mark the payment failed. transaction.paid is deliberately not enough: the package notes that it fires first and can still be reversed.
The package’s central point is that Paddle sells to the shopper, not the store, and decides the tax on the sale itself. A store on Paddle should leave GoCommerce’s tax rates empty and let the total it sends be the price the shopper pays; configuring both charges tax twice. That is documented rather than enforced, because the engine cannot tell which of the two a store intends.
Configuration
Two settings are required: the API key and the notification destination’s signing secret. Set them in Config from your own main(), or leave Config empty and fill them in under Settings › Payment methods.
| Setting | Environment variable | Required | What it does |
|---|---|---|---|
APIKeyAPI key | PADDLE_API_KEY | Yes | Sent as a bearer token. It needs transaction.write to create a transaction and adjustment.write to refund one. |
WebhookSecretNotification signing secret | PADDLE_NOTIFICATION_SECRET | Yes | Verifies the Paddle-Signature header. Required because, as the code puts it, without it any caller could mark orders paid. |
SandboxSandbox | — | No | Points the module at sandbox-api.paddle.com. Paddle issues separate keys for the two, so this is a deliberate flag rather than something read from the key. |
BaseURLAPI base URL | — | No | Overrides both hosts, for tests. |
WebhookTolerance | — | No | How old a signed notification may be. Five minutes by default; Paddle’s own SDK helpers default to five seconds. Go only. |
Client | — | No | Replaces the HTTP client, which otherwise times out after 20 seconds. Go only; not in the panel. |
The module reads its Config struct, not the environment. The variable names are the ones the package’s own example or the reference binary uses; in your own main() you choose where each value comes from. Where a setting has a panel label, the admin’s settings drawer can hold it too, and a value typed there wins over Config.
Setting it up
- Add a notification destinationIn Paddle, send notifications to https://your-api-host/api/checkout/paddle/webhook — transaction.completed and transaction.payment_failed at least — and keep its secret.
- Empty the tax ratesPaddle works out the tax. Leave GoCommerce’s tax rates empty so the order total is the price the shopper pays.
- Install and configurePass paddle.New to gocommerce.New as below, or run the reference binary with -gateways and fill in Settings › Payment methods. Tick Sandbox while testing.
- Take a paymentPOST /api/checkout/paddle answers with a redirect intent whose url is Paddle’s checkout. transaction.completed marks the order paid.
main.go
import (
"os"
"github.com/itswadesh/gocommerce/core"
paddle "github.com/itswadesh/gocommerce/ext/payments-paddle"
)
app, err := gocommerce.New(cfg,
paddle.New(paddle.Config{
APIKey: os.Getenv("PADDLE_API_KEY"),
WebhookSecret: os.Getenv("PADDLE_NOTIFICATION_SECRET"),
Sandbox: true,
}),
) The package doc’s own example, with its imports; cfg is your gocommerce.Config. It points at the sandbox; drop Sandbox for live keys. Import path github.com/itswadesh/gocommerce/ext/payments-paddle.
How it works
-
Signed and timed
Paddle-Signature’s ts=…;h1=… is checked as an HMAC-SHA256 over the timestamp, a colon and the raw body. Several h1 values pass during a secret rotation; a timestamp outside the tolerance, early or late, is refused.
-
Each event once
Claims are keyed on Paddle’s event_id with INSERT … ON CONFLICT DO NOTHING, atomic across instances. A redelivery is answered 200 and changes nothing.
-
A failed update is retried
If marking the order paid fails, the claim is released and the webhook answers 500, so Paddle’s retry finds work to do.
-
One line per order
Paddle’s items belong to its own catalogue, so the module sends one ad-hoc line carrying the order total rather than mirroring the order into Paddle products. The order keeps the breakdown.
-
Refunds as adjustments
A refund reads the transaction back to find its line, then creates a refund adjustment for exactly the amount asked — always typed partial, so Paddle cannot turn a partial refund into a full one. The adjustment id is recorded; a rejected one comes back as an error.
-
Amounts as strings
Amounts go to Paddle as integer strings in minor units, never as floats, so nothing picks up a rounding error on the way through JSON.
What it does not do
Read these before an order depends on it. No store is known to run GoCommerce in production yet, so these come from the code, not from anyone’s experience.
- Tax is Paddle’sLeave GoCommerce’s tax rates empty. The module does not check, and a store that configures both charges tax twice.
- One line, one tax categoryEvery order is sent as a single line with Paddle’s standard tax category, whatever the products in it are.
- Needs a checkout URLA transaction that comes back without one is refused, with a note to check that the Paddle account has an approved default payment link.
- Adjustments are only loggedadjustment.created and adjustment.updated are logged, not applied. A refund made in Paddle’s dashboard does not change the GoCommerce order.
- Orders, not subscriptionsThe module sells an order once. Events for anything this store did not start, such as a renewal billed in Paddle, are acknowledged and ignored.
FAQ
Questions about the Paddle module
Why should GoCommerce’s tax rates be empty?
Because Paddle is the merchant of record: it sells to the shopper and adds the tax it decides on. If GoCommerce has already added tax to the total, the shopper pays it twice. The package documents this rather than zeroing tax itself, because it cannot know what a store intends.
How do I test against Paddle’s sandbox?
Switch on Sandbox and use sandbox keys. Paddle issues separate keys for sandbox and live, so the module takes an explicit flag instead of guessing from the key.
Why does it wait for transaction.completed?
The package explains that transaction.paid fires first and can still be reversed, so settling on it would mark orders paid that Paddle has not finished collecting. transaction.completed is the event that means the money arrived.
Can I use it without writing Go?
Yes. The reference binary’s -gateways flag installs every payment module with an empty Config. Switch Paddle on under Settings › Payment methods and fill in the key, the secret and the sandbox flag there.
Is this a Paddle partnership?
No. The module calls Paddle’s public API with keys you supply. Paddle does not endorse GoCommerce, and no store is known to run this module in production yet.
Source
Everything on this page is read from ext/payments-paddle in the GoCommerce repository, MIT licensed. When this page and the code disagree, the code is right and this page is out of date.Paddle is a trademark of its owner; this module talks to its public API and implies no endorsement. See trademarks.
Try it against a store of your own
The one-command stack gives you GoCommerce’s API and admin on your own machine in minutes. Add this module to it and try it on test orders before a real one depends on it.