Record payments from other sources
Record purchases and subscriptions you sell outside the stores, such as through another payment provider, bank transfers or vouchers, so they grant access and appear in revenue analytics.
Some customers pay you outside the App Store, Google Play and Stripe: through another payment provider, by bank transfer, or with a voucher. A payment source lets you report those payments to Voidhash. Each one you report becomes a purchase like a store purchase: it grants the product's access, counts in revenue analytics and sends your webhooks.
You report what happens as it happens: each payment and refund, and each change to a subscription. If you also connect an endpoint on your server, Voidhash can import the source's products and history and ask about a subscription it hasn't heard from.
Voidhash cannot check these payments with a store, so it records them as you report them. Their
revenue and webhooks are marked verified: false, so you can tell reported revenue from verified
revenue.
Add a payment source
In Studio, open Settings → Payment providers, add a Payment source, and give it a name and
a slug such as bank-transfer or vouchers. The slug is the source's address in the API and
cannot be changed later. Add one source for each place you take payments; a project can have as
many as you need.
Map your products
A source names what it sells with its own product keys, for example LIFETIME or the voucher
code's product. On the source's page, map each key to the Voidhash product it grants. A payment for
a key you have not mapped yet waits, and is recorded as soon as you map it.
Create a secret key
Report payments from your backend with a project secret key. A key with full access works; to give
a key only what it needs, create it with the project:purchases:write scope.
Report each payment
Send each payment once it has settled, under an id that is unique within the source, such as the bank transfer's reference:
curl -X PUT "https://api.voidhash.com/api/v1/payment-sources/bank-transfer/transactions/tr_10042" \
-H "x-secret-key: $VOIDHASH_SECRET_KEY" \
-H "content-type: application/json" \
-d '{
"type": "charge",
"distinctId": "user_123",
"productKey": "LIFETIME",
"amount": 4900,
"currency": "EUR",
"fee": 0,
"tax": 818,
"occurredAt": "2026-10-20T09:30:00Z"
}'You can also record a payment by hand from the source's page in Studio, with Record payment.
What a payment contains
| Field | Notes |
|---|---|
type | charge for a payment, refund to give money back. |
distinctId | The identifier your app passes to identify(), or the anonymous id of the web SDK. |
customer.externalId | Your source's id for the customer, when you have no distinct id. See below. |
productKey | The source's product key, mapped to a product in Studio. Charges only. |
amount | What the customer paid, tax included, in minor units (cents rather than euros, for example). |
currency | ISO 4217 code, such as EUR. Revenue analytics converts it to USD at the day's rate. |
fee | Optional. What the source kept as its commission, so proceeds are what you receive. |
tax | Optional. The tax included in amount. |
occurredAt | When the customer paid. Revenue is dated by it. |
subscriptionId | The subscription a charge pays for. See Subscriptions. |
periodStart, periodEnd | The subscription period a charge pays for. Charges with subscriptionId only. |
refundsTransactionId | The id of the charge a refund returns money for. Refunds only. |
environment | production (the default) or sandbox. Sandbox payments stay out of production revenue. |
The response tells you whether the payment was recorded (status: "applied", with its
purchaseId and the bookedAmount it recorded) or waits (status: "pending"), and why:
product_not_mapped until you map its product key, or charge_not_recorded for a refund whose
charge you have not reported yet. A pending payment is recorded on its own once what it waits for
is there. If it turns out invalid by then, for example a refund in another currency than its
charge, it is dropped and you can send it again, corrected, under the same id.
Retries and corrections
Reporting a payment is safe to retry. Sending the same id with the same body again changes nothing
and returns the same result. Sending the same id with a different body is rejected with 409 Conflict: a recorded payment is never edited. To correct one, refund it and report the right
payment under a new id. A request that is rejected, or fails before anything is recorded, does not
take its id, so you can send it again.
Refunds
Report a refund under its own id, naming the charge in refundsTransactionId. It takes its
customer and product from the charge. You can repeat the charge's distinctId, customer and
productKey, but they must match it. Its currency must be the charge's.
- A refund of everything left of the charge ends the customer's access.
- A refund of less is partial: it reduces revenue and leaves access in place. Further refunds can return the rest, and the one that returns the last of it ends access.
- A refund cannot return more than is left of the charge. When refunds of the same charge arrive
at the same moment, a later one records only what the others left; its
bookedAmountsays what it recorded. - A charge of 0, such as a free voucher, is ended with a refund of 0.
Customers without a distinct id
When the person who paid has no distinct id yet, name them with customer.externalId, your
source's id for them. Voidhash records the purchase under a stand-in person for that customer, and
every later payment with the same customer.externalId belongs to the same person.
Once you know who the customer is, report their next payment with both distinctId and
customer.externalId. Voidhash merges the stand-in into that person, so the purchases recorded so
far grant them access too.
In webhooks and analytics
Purchases from a payment source send the usual purchase.completed and purchase.refunded
webhooks with provider: "external", source set to the source's slug, and verified: false.
Store purchases carry verified: true. Revenue analytics events carry the same labels, so you can
break revenue down by source.
Subscriptions
A subscription has two parts. Its payments are transactions like any other: each charge pays for one period and books revenue. Its state says whether it renews, is past due or has ended, and decides access. Report both, from the events your provider sends.
Report the state
Send the subscription's latest state whenever it changes, under the subscription's id:
curl -X PUT "https://api.voidhash.com/api/v1/payment-sources/paddle/subscriptions/sub_9" \
-H "x-secret-key: $VOIDHASH_SECRET_KEY" \
-H "content-type: application/json" \
-d '{
"distinctId": "user_123",
"productKey": "pri_monthly",
"status": "active",
"currentPeriodStart": "2026-10-01T00:00:00Z",
"currentPeriodEnd": "2026-11-01T00:00:00Z",
"willRenew": true,
"updatedAt": "2026-10-01T00:00:05Z"
}'| Field | Notes |
|---|---|
distinctId, customer.externalId | The subscriber, as for a payment. |
productKey | The source's product key, mapped to a subscription product in Studio. |
status | trialing, active, past_due, canceled or expired. See below. |
currentPeriodStart, currentPeriodEnd | The period the subscriber has now. |
willRenew | false once the subscriber turned renewal off; access lasts to the period's end. |
canceledAt | Optional. When a canceled subscription ended. |
updatedAt | When your provider last changed the subscription. A state older than the one Voidhash has is ignored, so events may arrive in any order. |
environment | production (the default) or sandbox. |
trialingstarts a free trial for the current period.activekeeps access. WithwillRenew: falsethe subscription ends at the period's end; turning renewal back on withwillRenew: truerenews it again.past_duekeeps access while your provider retries the payment.canceledends access atcanceledAt, andexpiredat the end of the current period.
The response says whether the state was applied, is superseded by a newer one, or is
pending: product_not_mapped until you map its product key, or charge_not_recorded until its
first paid period's charge arrives. A pending state is applied on its own once that happens.
Report each payment
A subscription's charge names the subscription and the period it pays for. The first one starts the subscription, later ones renew it, and the first paid period after a trial converts the trial:
{
"type": "charge",
"distinctId": "user_123",
"productKey": "pri_monthly",
"amount": 999,
"currency": "EUR",
"occurredAt": "2026-10-01T00:00:03Z",
"subscriptionId": "sub_9",
"periodStart": "2026-10-01T00:00:00Z",
"periodEnd": "2026-11-01T00:00:00Z"
}A refund of a subscription payment returns its revenue. Access follows the subscription's state, so
when the refund also ends the subscription, report the canceled state too.
Connect an endpoint
With an endpoint on your server, Voidhash can call your provider through your code:
- Import products: the source's products appear in Studio's product import, ready to map.
- Import history: Voidhash reads the subscriptions, payments and refunds from before you connected the source and records them like reported ones. Anything already recorded under its id is not recorded twice.
- Check on subscriptions: when a subscription's period ends without a renewal payment or a final state, Voidhash asks your endpoint for its state.
- Cancel and refund from Voidhash: cancel a subscription or refund a payment from the person's page in Studio, through the API or through your agent. Voidhash asks your endpoint to do it at the provider and records what it answers.
- Start checkouts: your app asks Voidhash for a checkout for the signed-in person, and your endpoint creates it at the provider.
Voidhash's server SDKs implement the endpoint: you write a function for each thing you support, and the SDK checks each request's signature, validates what your functions return, and answers in the format Voidhash expects. Push payments with the same SDK.
import { defineProvider } from "@voidhash/node/providers";
export const provider = defineProvider({
source: "paddle",
signingSecret: process.env.VOIDHASH_PROVIDER_SECRET!,
catalog: {
async list({ cursor }) {
const page = await paddle.prices.list({ after: cursor });
return {
items: page.data.map((price) => ({
key: price.id,
name: price.description,
kind: price.billingCycle ? "subscription" : "one_time",
})),
nextCursor: page.nextCursor ?? null,
};
},
},
});
// A route in any framework that speaks web requests, such as Next.js, Hono or Bun
export const POST = provider.handler;type Paddle struct{ api *paddle.SDK }
func (p *Paddle) ListCatalog(ctx context.Context, req provider.CatalogRequest) (provider.CatalogPage, error) {
prices, next, err := p.api.ListPrices(ctx, req.Cursor)
if err != nil {
return provider.CatalogPage{}, err
}
page := provider.CatalogPage{NextCursor: next}
for _, price := range prices {
page.Items = append(page.Items, provider.CatalogProduct{
Key: price.ID, Name: price.Description, Kind: provider.ProductKindSubscription,
})
}
return page, nil
}
handler, err := provider.NewHandler("paddle", os.Getenv("VOIDHASH_PROVIDER_SECRET"), &Paddle{api: api})
mux.Handle("POST /voidhash/provider", handler)final class PaddleProvider implements CatalogSource
{
public function listCatalog(CatalogRequest $request): CatalogPage
{
$page = $this->paddle->prices->list(after: $request->cursor);
return new CatalogPage(
items: array_map(
fn ($price) => new CatalogProduct(key: $price->id, name: $price->description, kind: ProductKind::Subscription),
$page->data,
),
nextCursor: $page->next,
);
}
}
$handler = new ProviderHandler('paddle', $_ENV['VOIDHASH_PROVIDER_SECRET'], new PaddleProvider($paddle));
$handler->respondToGlobals();impl CatalogSource for Paddle {
async fn list_catalog(&self, req: CatalogRequest) -> Result<CatalogPage, ProviderError> {
let page = self.api.prices(req.cursor.as_deref()).await.map_err(ProviderError::other)?;
Ok(CatalogPage {
items: page.data.into_iter()
.map(|price| CatalogProduct::new(price.id, price.description, ProductKind::Subscription))
.collect(),
next_cursor: page.next,
})
}
}
let handler = ProviderHandler::new("paddle", secret).catalog(paddle);
let app = Router::new().route("/voidhash/provider", handler.into_axum());Add the other operations the same way, and only those your provider supports:
| Operation | Returns |
|---|---|
catalog.list | Pages of the products you sell, with their product keys. |
history.list | Pages of subscriptions, then of payments and refunds, in the shape you report them. |
subscription.get | One subscription's current state, or none. |
subscription.cancel | The subscription's state after you canceled it at the period's end, or at once. |
transaction.refund | The refund you made at the provider: an amount, or everything left of the payment. |
checkout.create | Where to send a buyer to pay for a product key, for the given distinct id and return URLs. |
Then connect it in Studio:
- On the source's page, enter the endpoint's URL under Provider endpoint and select Connect. Voidhash creates a signing secret and shows it once.
- Set the secret on your server, for example as
VOIDHASH_PROVIDER_SECRET. - Select Test connection. Voidhash sends a signed request and lists what your endpoint implements.
The endpoint must be reachable over HTTPS at a public address. Voidhash waits up to 10 seconds for an answer. It retries reads that failed on your side, but sends a cancellation, refund or checkout only once. Rotate secret replaces the secret; requests signed with the old one stop verifying, so update your server right after.
Cancel, refund and start checkouts from Voidhash
With an endpoint that implements them, these happen at your provider and are recorded in Voidhash like reported changes. When your provider also sends you the same change as an event and you report it, nothing is recorded twice.
-
Cancel a subscription: on the person's page in Studio, choose Cancel on the subscription, or call the API with
atPeriodEnd(trueby default):curl -X POST "https://api.voidhash.com/api/v1/payment-sources/paddle/subscriptions/sub_9/cancel" \ -H "x-secret-key: $VOIDHASH_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "atPeriodEnd": true }' -
Refund a payment: choose Refund on the purchase in Studio, or call
POST /api/v1/payment-sources/{source}/transactions/{id}/refundwith an optionalamountin minor units (everything left of the payment without one) andreason. -
Start a checkout: in your web app, ask the web SDK for a checkout for the signed-in person and send them to it. The product key must be mapped to a product.
const { url } = await voidhash.createCheckout({ source: "paddle", productKey: "pri_monthly", successUrl: "https://app.example.com/thanks", cancelUrl: "https://app.example.com/pricing", }); window.location.assign(url);The payment arrives through your usual reports, attributed to the person who started the checkout.
If your endpoint fails, Voidhash records nothing and answers with the reason
(424, PaymentSourceEndpointError), so you can retry.