VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
Products and perks
Paywall locations
CLI
Paywall components
Components
Develop a component
Editor panels
Component definition
Publishing
Installing and capabilities
SDKs
Swift
Kotlin
React Native
Web
Store setup
Connect the App Store
Connect Google Play
Connect Stripe
API libraries
Node.js
PHP
Rust
Go
Resources
MCP server
Ad measurement
Meta ads
Migrate in observer mode
Delete a person's data

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

FieldNotes
typecharge for a payment, refund to give money back.
distinctIdThe identifier your app passes to identify(), or the anonymous id of the web SDK.
customer.externalIdYour source's id for the customer, when you have no distinct id. See below.
productKeyThe source's product key, mapped to a product in Studio. Charges only.
amountWhat the customer paid, tax included, in minor units (cents rather than euros, for example).
currencyISO 4217 code, such as EUR. Revenue analytics converts it to USD at the day's rate.
feeOptional. What the source kept as its commission, so proceeds are what you receive.
taxOptional. The tax included in amount.
occurredAtWhen the customer paid. Revenue is dated by it.
subscriptionIdThe subscription a charge pays for. See Subscriptions.
periodStart, periodEndThe subscription period a charge pays for. Charges with subscriptionId only.
refundsTransactionIdThe id of the charge a refund returns money for. Refunds only.
environmentproduction (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 bookedAmount says 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"
  }'
FieldNotes
distinctId, customer.externalIdThe subscriber, as for a payment.
productKeyThe source's product key, mapped to a subscription product in Studio.
statustrialing, active, past_due, canceled or expired. See below.
currentPeriodStart, currentPeriodEndThe period the subscriber has now.
willRenewfalse once the subscriber turned renewal off; access lasts to the period's end.
canceledAtOptional. When a canceled subscription ended.
updatedAtWhen your provider last changed the subscription. A state older than the one Voidhash has is ignored, so events may arrive in any order.
environmentproduction (the default) or sandbox.
  • trialing starts a free trial for the current period.
  • active keeps access. With willRenew: false the subscription ends at the period's end; turning renewal back on with willRenew: true renews it again.
  • past_due keeps access while your provider retries the payment.
  • canceled ends access at canceledAt, and expired at 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.

voidhash-provider.ts
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:

OperationReturns
catalog.listPages of the products you sell, with their product keys.
history.listPages of subscriptions, then of payments and refunds, in the shape you report them.
subscription.getOne subscription's current state, or none.
subscription.cancelThe subscription's state after you canceled it at the period's end, or at once.
transaction.refundThe refund you made at the provider: an amount, or everything left of the payment.
checkout.createWhere to send a buyer to pay for a product key, for the given distinct id and return URLs.

Then connect it in Studio:

  1. 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.
  2. Set the secret on your server, for example as VOIDHASH_PROVIDER_SECRET.
  3. 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 (true by 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}/refund with an optional amount in minor units (everything left of the payment without one) and reason.

  • 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.

On this page

Add a payment sourceMap your productsCreate a secret keyReport each paymentWhat a payment containsRetries and correctionsRefundsCustomers without a distinct idIn webhooks and analyticsSubscriptionsReport the stateReport each paymentConnect an endpointCancel, refund and start checkouts from Voidhash