Payments
Link a payment business and handle checkout in a public voting interface.
Use these organizer endpoints from your server with a read or write key. There is no API endpoint to create a payment business. Connect and complete the business setup in Pepvote first.
Use the public payment endpoint from your server with a vote key when you need a voter-facing payment status.
Endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /payment-businesses | read | List your payment businesses. |
GET | /voting-events/:idOrCode/payment-business | read | Get the business linked to an event. |
PUT | /voting-events/:idOrCode/payment-business | write | Link a business to an event. |
DELETE | /voting-events/:idOrCode/payment-business | write | Unlink the business from an event. |
GET | /voting-events/:idOrCode/payments/:id | read | Get an organizer payment attempt. |
GET | /public/voting-events/:idOrCode/payments/:id | vote | Get a voter payment attempt. |
Payment businesses
GET /payment-businesses returns businesses you can use. Each business includes supportedCurrencies. Use only one of these currencies when you set pricing.
curl "https://api.pepvote.com/payment-businesses" \
-H "Authorization: Bearer $PEPVOTE_API_KEY" \
-H "User-Agent: my-app/1.0"{
"data": [
{
"id": "j57business01",
"provider": "stripe",
"displayName": "Northstar Ltd",
"country": "US",
"ready": true,
"supportedCurrencies": [
"usd",
"cad"
]
}
],
"nextCursor": null
}Link a business
Link one ready business before you set paid pricing. A business can be unlinked later. The event must have the matching pay-to-vote or pay-to-register-candidate setting and plan before it can use checkout.
curl -X PUT "https://api.pepvote.com/voting-events/BOARD26/payment-business" \
-H "Authorization: Bearer $PEPVOTE_API_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-d '{"paymentBusinessId":"j57business01"}'{
"data": {
"id": "j57business01",
"provider": "stripe",
"displayName": "Northstar Ltd",
"country": "US",
"ready": true,
"supportedCurrencies": [
"usd",
"cad"
]
}
}Payment status
Poll GET /public/voting-events/:idOrCode/payments/:id from your server while your interface waits for payment. You can also wait for the payment.succeeded webhook. The organizer endpoint returns the same payment attempt for an event the organizer can access.
status moves through created, pending, succeeded, and fulfilled, or ends in failed or cancelled. amountMinor is in the currency's smallest unit (500 is $5.00), unlike the amount in a 402 response, which is in the main unit. confirmationCode is set once a paid ballot has been cast, so a client that lost the cast response can recover it here.
curl "https://api.pepvote.com/public/voting-events/POLL26/payments/j57payment01" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0"{
"data": {
"id": "j57payment01",
"votingEventId": "j57event01",
"purpose": "vote",
"status": "succeeded",
"provider": "stripe",
"amountMinor": 500,
"currency": "usd",
"confirmationCode": null,
"createdAt": 1780000000000,
"updatedAt": 1780000030000
}
}Checkout flow
Three public endpoints can require payment. Each one plays both roles: the first call starts checkout and returns 402, and a second call with only paymentAttemptId finishes it.
| Flow | Endpoint | First call | Second call |
|---|---|---|---|
| Pay to vote | POST /public/voting-events/:idOrCode/ballot/cast | Ballot fields plus returnUrl | { "paymentAttemptId": "..." } returns 201 with the confirmation code |
| Paid candidate registration | POST /public/voting-events/:idOrCode/register/candidate | Candidate fields plus returnUrl | { "paymentAttemptId": "..." } returns 201 with the candidate |
| Quadratic credit top-up | POST /public/voting-events/:idOrCode/quadratic-credits/checkout | Purchase fields plus returnUrl | { "paymentAttemptId": "..." } returns 200 with the new balance |
The first call saves the ballot, candidate details, or credit request on the payment attempt, so the second call needs nothing else.
Both providers work the same way: send the payer to the checkoutUrl in the response. Paystack returns it as paystack.checkoutUrl; Stripe returns a hosted Checkout page as stripe.checkoutUrl. You never load provider JavaScript or handle card details.
Include returnUrl in the request body. After the payer finishes, the provider redirects them there with paymentAttemptId appended as a query parameter. returnUrl is required when the event pays through Stripe (Stripe's page needs a destination) and optional for Paystack, which falls back to the callback configured in the merchant's Paystack Dashboard. You can also send cancelUrl for Stripe; it becomes the back link on the hosted page. Both must be absolute URLs. Use https:// for a live integration.
After payment succeeds, call the endpoint that returned 402 again from your server with { "paymentAttemptId": "..." } and nothing else. Do not resend the votes, candidate details, or credit request. Poll GET /public/voting-events/:idOrCode/payments/:id or wait for the payment.succeeded webhook before that second call. The redirect alone is not proof of payment; a payer can close the tab before it fires, so the second call is what verifies and fulfils.
The 402 response
| Field | Type | Description |
|---|---|---|
paymentRequired | true | Always true on a 402. |
paymentAttemptId | string | Send this back to finish, and use it to read payment status. |
amount | number | Price in the currency's main unit, for example 5 for $5.00. |
currency | string | Lowercase ISO code. |
provider | stripe or paystack | Which object below is present. |
stripe.checkoutUrl | string | Stripe-hosted page. Valid for one hour. |
stripe.checkoutSessionId | string | Stripe's cs_ id, for your logs. |
paystack.checkoutUrl | string | Paystack-hosted page. |
paystack.accessCode | string | For Paystack's inline popup, if you prefer it over the URL. |
paystack.reference | string | Paystack transaction reference. |
credits, pricingModel | number, string | Quadratic credit checkout only. |
next | string | Human-readable reminder of the second call. |
Paystack checkout
curl -X POST "https://api.pepvote.com/public/voting-events/POLL26/ballot/cast" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-d '{"visitorId":"vi_ab12ab12ab12ab12ab12ab12ab12ab12","email":"voter@example.com","returnUrl":"https://your-app.example/paid","votes":[{"positionId":"j57position01","candidateId":"j57candidate01"}]}'{
"data": {
"paymentRequired": true,
"paymentAttemptId": "j57payment01",
"amount": 5,
"currency": "ngn",
"provider": "paystack",
"paystack": {
"checkoutUrl": "https://checkout.paystack.com/example",
"accessCode": "access_code",
"reference": "reference"
},
"next": "Send the payer to the provider checkoutUrl. After the payment succeeds, call this endpoint again with { paymentAttemptId } to finish."
}
}Stripe checkout
curl -X POST "https://api.pepvote.com/public/voting-events/POLL26/ballot/cast" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-d '{"visitorId":"vi_ab12ab12ab12ab12ab12ab12ab12ab12","returnUrl":"https://your-app.example/paid","cancelUrl":"https://your-app.example/cancelled","votes":[{"positionId":"j57position01","candidateId":"j57candidate01"}]}'{
"data": {
"paymentRequired": true,
"paymentAttemptId": "j57payment01",
"amount": 5,
"currency": "usd",
"provider": "stripe",
"stripe": {
"checkoutUrl": "https://checkout.stripe.com/c/pay/cs_example",
"checkoutSessionId": "cs_example"
},
"next": "Send the payer to the provider checkoutUrl. After the payment succeeds, call this endpoint again with { paymentAttemptId } to finish."
}
}The payer lands on https://your-app.example/paid?paymentAttemptId=j57payment01 after paying. Omitting returnUrl on a Stripe event returns 422 with field: "returnUrl".
Finish checkout
Use the same URL and send only the payment attempt ID after the provider reports success.
curl -X POST "https://api.pepvote.com/public/voting-events/POLL26/ballot/cast" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: paid-ballot-001" \
-d '{"paymentAttemptId":"j57payment01"}'{
"data": {
"confirmationCode": "PV-9D4R",
"votesCount": 1
}
}register/candidate and quadratic-credits/checkout finish the same way: the same URL, a body of only paymentAttemptId.
Last updated September 6, 2026