Partner Regrant API
For partners: redistributing credit you hold to your own users through the API — what you receive from us, the constraints, and the API contract.
Before you start: what you will receive from us
Send us the wallet address you will sign with. Once it is seated and your credit has been granted to it, we send you a partner kit with the five items below — all you need to make the first call. Then follow the complete example.
Signed up with Google, X, Discord or TikTok? Your credit sits on a Privy wallet. Use Log in at the top right of this page, and you can sign a request right here without the private key ever leaving Privy. You only need the key if your own backend is going to sign — then open the wallet menu and choose Export private key. The key is shown in Privy's own window — we never see it — so store it as you would any private key.
| Signer wallet | The address you gave us, now seated as partner. It signs every request and holds the credit. |
| API base URL | The Pay API host. It is not this site and is not published, so it comes in the kit. |
| source_lot_id | The lot you spend from: 32 hex characters, not the campaign id. Your wallet has no endpoint that lists its own lots. |
| Lot amount, expiry and scope | What you may hand on in total, the latest expiry a row may carry, and the service your credit is pinned to, if any. |
| external_ref prefix | Your wallet address, lowercased, then a colon. Every row's reference has to start with it. |
1. POST /v1/regrants
POST https://<pay-api-host>/v1/regrants
# host is NOT this site — it is in your partner kit
X-Pay-Timestamp: 1789000000 # unix seconds; within 5 minutes of our clock
X-Pay-Signature: 0x… # personal_sign over the exact string below
{"source_lot_id": "01a0…",
"items": [
{"external_ref": "0xacme…:sep-airdrop:u-1",
"account_ref": {"kind": "evm_address", "id": "0xAAA…"},
"amount_micros": 10000000, # $10.00
"expires_at": "2026-10-15T00:00:00Z"},
{"external_ref": "0xacme…:sep-airdrop:u-2",
"account_ref": {"kind": "evm_address", "id": "0xBBB…"},
"amount_micros": 10000000} # omitted → inherits your own lot's
]}- At most 500 rows per call. Amounts are integer micro-USD: $10.00 is 10000000.
- Recipients are wallet addresses. A wallet that has never used Pay gets an account automatically; email addresses and login ids are not accepted.
- Rows are paid in request order. A row asking for more than your lot has left is rejected with lot_unusable, so put the payments that matter first, and read each row's outcome rather than assuming every row after the first rejection failed.
- expires_at is optional and capped at your lot's expiry. A later date is not an error, you just get the lot's date, so read it back from the response.
- Each row may carry an optional service. Leave it out to inherit your lot's; if your credit is locked to one service, a different one is rejected (scope_widened).
- A payment cannot be undone through the API. Check the wallet list before sending; to revoke a row sent to the wrong address, contact your BD.
Signing
pay-v2|POST|/v1/regrants|1789000000|9b74c9897bac770ffc029102a200c5de…
pay-v2 a literal constant — not a name you pick, not a version
POST the method, uppercase
/v1/regrants the path, including the query string if there ever is one
1789000000 the same unix seconds you send as X-Pay-Timestamp
9b74c989… sha256 of the request body, lowercase hex, 64 characters —
the hash, never the body itself
Joined with "|": one line. No newlines, no spaces, no trailing separator.
Sign that line with EIP-191 personal_sign; send 0x + 65 bytes r||s||v.
v may be 0/1 or 27/28 — both recover, so send whatever your library gives you.
Hash the exact bytes you put on the wire — re-serialising the JSON before
hashing gives a different digest, and therefore a different signer.A signature over the wrong string still recovers an address, just not yours, and comes back as 403. If you get 403 while sure your wallet is set up, compare the signing string character by character first. Do not send X-Actor-Subject: this endpoint rejects it with 400.
2. Complete example
Set PAY_API_URL, PARTNER_PRIVATE_KEY and SOURCE_LOT_ID from your kit, replace the recipient, and run it on your server. Replace sep-airdrop with a new batch id for every round, or the second round reports every row as duplicate and pays nobody.
Node.js
// npm i viem (Node 18+, ESM)
import { createHash } from "node:crypto";
import { privateKeyToAccount } from "viem/accounts";
const PAY_API = process.env.PAY_API_URL; // from your partner kit, no trailing slash
const account = privateKeyToAccount(process.env.PARTNER_PRIVATE_KEY); // the seated wallet
const prefix = account.address.toLowerCase();
// Serialise ONCE. These exact bytes are hashed, signed and sent.
const body = JSON.stringify({
source_lot_id: process.env.SOURCE_LOT_ID,
items: [
{ external_ref: `${prefix}:sep-airdrop:u-1`,
account_ref: { kind: "evm_address", id: "0xAAA…" },
amount_micros: 10_000_000 }, // $10.00
],
});
const path = "/v1/regrants";
const ts = String(Math.floor(Date.now() / 1000));
const bodyHash = createHash("sha256").update(body).digest("hex");
const signature = await account.signMessage({ // EIP-191 personal_sign
message: `pay-v2|POST|${path}|${ts}|${bodyHash}`,
});
const res = await fetch(PAY_API + path, {
method: "POST",
headers: { "Content-Type": "application/json",
"X-Pay-Timestamp": ts, "X-Pay-Signature": signature },
body,
});
const out = await res.json();
if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(out)}`); // whole batch refused
for (const it of out.items) console.log(it.external_ref, it.outcome, it.reason ?? "");Python
# pip install eth-account requests
import hashlib, json, os, time
import requests
from eth_account import Account
from eth_account.messages import encode_defunct
PAY_API = os.environ["PAY_API_URL"] # from your partner kit, no trailing slash
acct = Account.from_key(os.environ["PARTNER_PRIVATE_KEY"]) # the seated wallet
prefix = acct.address.lower()
# Serialise ONCE. These exact bytes are hashed, signed and sent.
body = json.dumps({
"source_lot_id": os.environ["SOURCE_LOT_ID"],
"items": [
{"external_ref": f"{prefix}:sep-airdrop:u-1",
"account_ref": {"kind": "evm_address", "id": "0xAAA…"},
"amount_micros": 10_000_000}, # $10.00
],
}, separators=(",", ":")).encode()
path = "/v1/regrants"
ts = str(int(time.time()))
body_hash = hashlib.sha256(body).hexdigest()
signed = acct.sign_message(encode_defunct(text=f"pay-v2|POST|{path}|{ts}|{body_hash}"))
signature = "0x" + bytes(signed.signature).hex() # EIP-191 personal_sign
res = requests.post(PAY_API + path, data=body, timeout=30, headers={
"Content-Type": "application/json",
"X-Pay-Timestamp": ts, "X-Pay-Signature": signature})
res.raise_for_status() # whole batch refused
for it in res.json()["items"]:
print(it["external_ref"], it["outcome"], it.get("reason", ""))Or sign here, with the wallet you are logged in with
Sign one request with the wallet you are logged in with here, then send it yourself. Nothing is sent from this page, and no private key is involved — the wallet signs and returns only the signature.
3. external_ref and retries
It must start with your own wallet address, lowercased, followed by a colon, or the row is rejected (external_ref_prefix).
Retrying is safe. A reference is remembered permanently: sending it again does not pay twice, it reports duplicate. After a timeout, resend the same batch unchanged.
0xacme…:sep-airdrop:u-1 0xacme…:sep-airdrop:u-2 └prefix┘ └─batch id─┘ └user┘ keep the whole thing under 120 characters
| Mistake | Consequence |
|---|---|
| Changing any part of the reference on a retry | Treated as a new payment — that user is paid twice |
| Reusing last round's batch id for a new round | Every row reports as already done and nobody is paid |
| Repeating one reference inside a single batch | The second row reports duplicate and looks like a row that vanished |
4. Response and errors
200 OK
{"granted_count": 2, "duplicate_count": 0, "rejected_count": 0,
"granted_micros": 20000000,
"items": [
{"external_ref": "0xacme…:sep-airdrop:u-1", "outcome": "granted",
"account_id": "01a0…", "account_created": true, "lot_id": "01a0…",
"depth": 1, "amount_micros": 10000000,
"expires_at": "2026-10-15T00:00:00Z"},
{"external_ref": "0xacme…:sep-airdrop:u-2", "outcome": "granted",
"account_id": "01a0…", "account_created": true, "lot_id": "01a0…",
"depth": 1, "amount_micros": 10000000,
"expires_at": "2026-12-31T00:00:00Z"} # not specified, inherited from the lot
]}Results come back per row, and a failed row does not undo the ones that succeeded. Each row has one of three outcomes:
| outcome | Meaning | What to do |
|---|---|---|
| granted | Paid | Nothing |
| duplicate | This reference was already paid | Nothing — this is the retry mechanism working |
| rejected | See the reasons below | Depends on the reason |
Rejection reasons
| code | Reason and remedy |
|---|---|
| lot_unusable | Out of credit. The most common one, and it maps exactly to “the rows after the money ran out”. Ask BD to top up, then resend the same batch unchanged: what was already paid reports duplicate, and only the shortfall actually posts. |
| unknown_account | The recipient cannot be created. Wallets are created automatically; email addresses and login ids are not — nobody can prove who they belong to. Use a wallet address. |
| scope_widened | Your credit is locked to one service and the row asked for another. Leave service out to inherit yours. |
| external_ref_conflict | This reference was used before, for a different amount or recipient. It means the same (batch, user) pair was used for two different payments; give the new one a different batch id. |
| external_ref_prefix | The reference does not start with their wallet address. Add the prefix and resend. |
| invalid_amount | The amount is zero or negative. |
Whole-batch failures (not per row)
| Status | Reason |
|---|---|
| 400 | X-Actor-Subject was sent |
| 401 | The signature is malformed — no address could be recovered from it at all |
| 403 actor_not_authorized | The recovered address is not seated as a partner, or is seated but has no account yet (never been granted anything). A signature over the wrong message lands here too: it recovers someone else's address, and that address is not seated. |
| 404 unknown_lot | source_lot_id is not a lot you hold. A campaign id passed here returns this — not a 403, and not a per-row lot_unusable. |