Skip to content

Sponsored token transfer

Move an ERC-20 token for a user who holds no native gas token. Your backend submits the transfer through Photon; the relayer pays the gas and the cost is charged to your prepaid balance in USD.

This pattern fits onboarding flows: a fresh wallet receives tokens, and can act on them before it ever holds ETH.

Prerequisites

  • An API key with balance.
  • The token contract address and the sender's tokens already in place. For a transferFrom-style move, the sender must have approved your spender contract beforehand; for the simple case below, the tokens sit in an account your system controls.

1. Encode the transfer

The relayed call is ordinary calldata. For transfer(address,uint256):

import { encodeFunctionData, erc20Abi } from "viem";

const callData = encodeFunctionData({
  abi: erc20Abi,
  functionName: "transfer",
  args: ["0xRecipient…", 25_000_000n],   // 25 USDC (6 decimals)
});

2. Submit through Photon

curl -X POST https://photon.example.com/api/submit-tx \
  -H "api-key: $EDITH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "0xTokenContract…",
    "callData": "'"$CALL_DATA"'",
    "value": "0",
    "chainId": 84532,
    "maxUSD": 0.50,
    "label": "welcome-transfer:user-4821"
  }'

maxUSD caps what this transaction may cost you — if gas spikes past it, the transaction fails with TRANSACTION_001 instead of draining your balance. label is free-form and comes back on the record; use it to tie transactions to your own entities.

The response is immediate:

{
  "success": true,
  "data": { "txId": "68fa3450539a3c9d28bbca33", "estimatedCostUSD": 0.0071 },
  "timestamp": "2026-07-29T12:00:00.000Z"
}

3. Track it to completion

Either poll Get transaction status until status is EXECUTED, or receive the same terminal event on a webhook / WebSocket. The executed record carries executionTxHash (link it to a block explorer for your user) and costUSD — the actual amount charged.

If the transfer would revert (say, insufficient token balance), Photon's preflight catches it and the record lands on FAILED with revert details when you pass includeRevertInfo=true — your balance is not charged for deterministic reverts caught before broadcast.

Variations

  • Wait inline instead of polling — use Submit and wait with a timeoutMs up to 120000 when a synchronous UX is acceptable.
  • Many transfers at once — set shouldBatchInMulticall: true on each and Photon executes them as one atomic batch; see Batch transactions.