0G Pay
Docs / Partner Regrant API

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.

For partnersFor operators
EN中文

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 walletThe address you gave us, now seated as partner. It signs every request and holds the credit.
API base URLThe Pay API host. It is not this site and is not published, so it comes in the kit.
source_lot_idThe 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 scopeWhat 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 prefixYour wallet address, lowercased, then a colon. Every row's reference has to start with it.

1. POST /v1/regrants

Call the Pay API host from your partner kit, from your server. It is not this site. The Pay API sends no CORS headers, so a browser cannot call it, and your private key does not belong in a browser anyway. There is no API key: your signature identifies you. If a proxy of yours sits in front, still sign /v1/regrants, the path the Pay API receives.
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
MistakeConsequence
Changing any part of the reference on a retryTreated as a new payment — that user is paid twice
Reusing last round's batch id for a new roundEvery row reports as already done and nobody is paid
Repeating one reference inside a single batchThe 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:

outcomeMeaningWhat to do
grantedPaidNothing
duplicateThis reference was already paidNothing — this is the retry mechanism working
rejectedSee the reasons belowDepends on the reason

Rejection reasons

codeReason and remedy
lot_unusableOut 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_accountThe 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_widenedYour credit is locked to one service and the row asked for another. Leave service out to inherit yours.
external_ref_conflictThis 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_prefixThe reference does not start with their wallet address. Add the prefix and resend.
invalid_amountThe amount is zero or negative.

Whole-batch failures (not per row)

StatusReason
400X-Actor-Subject was sent
401The signature is malformed — no address could be recovered from it at all
403 actor_not_authorizedThe 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_lotsource_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.
← Back to Docs