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 names the source in webhooks and
analytics, 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. Link each key to the Voidhash product it grants, as you would a store's product: in
Products, open the product and add the key under the source. With an
endpoint connected, Import products lists the source's products instead,
ready to link. A payment for a key you have not linked yet waits, and is recorded as soon as you
link it.
Map as many keys to one product as you sell it under, for example a price per currency, region or pricing experiment. Each of them grants the same product.
A mapping applies to All environments by default. When your test system names a product differently, map its test key separately and choose Test only for it, and Production only for the live key.
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.
The key's environment decides where payments are recorded: a production key (vh_sk_…) records
production payments, and a test key (vh_sk_test_…) records test payments. Use a test key on
staging; see Staging on the same project.
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. The source's Payments API tab in Studio shows the address to send it to as its Transactions endpoint: it names your project and the source by their ids.
curl -X PUT "https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/payment-providers/$PAYMENT_SOURCE_ID/transactions/tr_10042" \
-H "Authorization: Bearer $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 with Record payment at the top of the source's page in Studio. The server libraries for Node.js, Go, PHP and Rust report payments, refunds and subscription states for you.
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 | Optional. production or test, and must match the key's environment. See below. |
The key you report with decides a payment's environment. A payment without environment is
recorded in the key's environment, and one whose environment disagrees with the key is rejected.
Test payments stay out of production revenue and never unlock access in production apps.
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, in
pendingReason: 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 (transaction_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.
Sell on your website
Your app and your provider run the checkout: your server chooses the price, creates the checkout at the provider and sends the buyer to it. Voidhash learns what was sold when you report the payment. To credit the payment to the visitor who bought, and to the campaign that brought them, carry their distinct id from the browser to the report:
- In the browser, read the buyer's distinct id with
voidhash.getDistinctId()and send it with your checkout request. It is the anonymous visitor's id, or your user id once you calledidentify(). For a signed-in user, your server already knows it: it is your own user id. - On your server, create the checkout at your provider and store the distinct id with the order, for example in the checkout's custom data or metadata, or in your own database.
- When the provider tells you the order was paid, report the payment with that
distinctId. When you do not have it, report the provider's customer id ascustomer.externalId; see Customers without a distinct id.
With the Node.js library, where paddleApi stands in
for your provider's SDK and paddle is the payment source:
// Your checkout route: the browser sends the distinct id with its request.
const checkout = await paddleApi.createTransaction({
priceId: "pri_monthly",
customData: { distinctId },
});
// Your provider's webhook, once the order is paid: report it with the same distinct id.
await paddle.charge(transaction.id, {
distinctId: transaction.customData.distinctId,
productKey: transaction.priceId,
amount: transaction.total,
currency: transaction.currencyCode,
occurredAt: transaction.billedAt,
});An anonymous visitor who buys before signing up is fine: the purchase lands on them, and moves to
your user with the rest of their history when you later call identify().
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.
Test payments carry environment: "test" and reach only test webhooks.
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/projects/$VOIDHASH_PROJECT_ID/payment-providers/$PAYMENT_SOURCE_ID/subscriptions/sub_9" \
-H "Authorization: Bearer $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",
"isAutoRenewing": 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. |
isAutoRenewing | 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 | Optional. production or test, and must match the key's environment. |
trialingstarts a free trial for the current period.activekeeps access. WithisAutoRenewing: falsethe subscription ends at the period's end; turning renewal back on withisAutoRenewing: 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, with pendingReason 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.
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. Each library's guide covers every operation, mounting the endpoint in your framework and testing it: Node.js, Go, PHP and Rust.
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 __construct(private readonly PaddleApi $paddle)
{
}
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', getenv('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. |
Then connect it in Studio:
- On the source's Provider endpoint tab, enter the endpoint's URL and select Save changes. 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 or refund only once. Rotate secret replaces the secret; requests signed with the old one stop verifying, so update your server right after.
Add a test endpoint
Every request Voidhash sends your endpoint carries an environment, production or test, like
the payments you report. When a staging deployment of your server reports your test payments with
a test key, connect it as the source's test endpoint, so Voidhash asks it about those payments
and your production deployment only about real ones. Mappings for all environments cover both
deployments; see Map your products when their product keys differ.
On the source's Provider endpoint tab, enter the staging deployment's URL under Test endpoint and save. It gets its own signing secret, shown once: set it on the staging server, then select its Test connection, which sends that endpoint a test request. Each endpoint has its own Rotate secret and Disconnect.
| Request | Goes to |
|---|---|
| Test: history import, subscription checks, cancel, refund | The test endpoint, or the endpoint when there is no test endpoint. |
| Production: history import, subscription checks, cancel, refund | The endpoint only. Voidhash never sends them to the test endpoint. |
| Product import | Both, each in its own environment. Their products are listed together, by key. |
When one of them cannot be read, for example before your production deployment is live, Import products still lists the other's products and says why the rest are missing.
Without a test endpoint, the endpoint receives requests of both environments. A source may
also have only a test endpoint while you try it on staging: its test requests work, and
production ones fail with payment_source_endpoint_not_configured until you add the endpoint.
Import history asks which environment to import: production needs the endpoint, test either
one.
Staging on the same project
Your staging deployment can report into the same Voidhash project as production:
- Create a test secret key for staging, ideally with only the
project:purchases:writescope. Everything it reports is recorded as test, whateverenvironmentsays, and a report whoseenvironmentdisagrees with the key is rejected. Staging cannot record production payments. - Connect the staging deployment as the source's test endpoint, so Voidhash sends it the test requests.
- Create staging's checkouts in your provider's sandbox, so they take no real money, and use the test publishable key on your staging website.
Test purchases never unlock access in production apps, even for a distinct id that also names someone in production. Only test keys and test webhooks see them, and Studio shows them under Test. See Environments.
Each deployment verifies requests with its own signing secret, so a request meant for one cannot be replayed against the other. Your handler can also refuse requests for the environment it does not serve.
Cancel and refund 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. It cancels at the end of the paid period, or at once with
isImmediate: true:curl -X POST "https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/payment-providers/$PAYMENT_SOURCE_ID/subscriptions/sub_9/cancel" \ -H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "isImmediate": false }' -
Refund a payment: choose Refund on the purchase in Studio, or call
POST /api/v1/projects/{projectId}/payment-providers/{paymentProviderId}/transactions/{externalId}/refundwith an optionalamountin minor units (everything left of the payment without one) andreason.
If your endpoint cannot cancel or refund, Voidhash records nothing and the error's message says
why. It is a 409 conflict when no endpoint is connected for the record's environment (a test
endpoint never receives production records), the endpoint does not implement the operation
or refused it (payment_source_endpoint_not_configured, payment_source_endpoint_unsupported,
payment_source_endpoint_rejected), and a 503 unavailable when it did not answer properly
(payment_source_endpoint_unavailable, payment_source_endpoint_invalid_response), which you can
retry.