Skip to main content
Goal: Accept your first payment in FLY end-to-end.
Prep time: ~15 minutes

Prerequisites

export API_BASE_URL="https://api.staging.blackbird.xyz/flynet/v1"
export ACCESS_TOKEN="..."        # OAuth access token (from /oauth/token)
export MERCHANT_ID="..."         # from your onboarding email; required
export CUSTOMER_USER_ID="..."    # the member paying; their UUID, which equals the token's sub claim / the id from /users/me
If you don’t have a MERCHANT_ID, your partner record isn’t payments-enabled. Ask via Support. See Payments for the credential model.
Tasting note - customer_user_id is the paying member’s UUID. For a member acting on their own behalf, that’s the sub claim on their access token: the same value /users/me returns as id. You don’t read it from a /users/{id} path; resolve it from the token.
The customer must hold enough FLY in their SPENDING wallet for confirm to succeed.

What you will use

  • POST /flynet/v1/payment_intents
  • POST /flynet/v1/payment_intents/{id}/confirm
  • GET /flynet/v1/payment_intents/{id}
  • Optionally: POST /flynet/v1/payment_intents/{id}/refund

Step 1: Create the intent

const res = await fetch(`${process.env.API_BASE_URL}/payment_intents`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ACCESS_TOKEN!}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    flynet_merchant_id: process.env.MERCHANT_ID,
    customer_user_id: process.env.CUSTOMER_USER_ID,
    amount: { value: "1000000000000000000", currency: "FLY" },
    description: "First payment recipe",
    idempotency_key: `recipe-first-payment-${Date.now()}`,
  }),
});

const intent = await res.json();
console.log(intent.id, intent.status);
curl -X POST "$API_BASE_URL/payment_intents" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"flynet_merchant_id\": \"$MERCHANT_ID\",
    \"customer_user_id\": \"$CUSTOMER_USER_ID\",
    \"amount\": { \"value\": \"1000000000000000000\", \"currency\": \"FLY\" },
    \"description\": \"First payment recipe\",
    \"idempotency_key\": \"recipe-first-payment-$(date +%s)\"
  }"
Response:
{
  "id": "{uuid}",
  "status": "pending",
  "amount": { "value": "1000000000000000000", "currency": "FLY" }
}
Save the id. You need it in the next step.
Tasting note - Use your own order ID as the idempotency key in production so network retries land on the same intent. Here we use a timestamp so re-running the recipe creates fresh intents. See Idempotency.
Chef’s warning - If create returns 404 resource_not_found "Flynet merchant not found", your MERCHANT_ID isn’t scoped to your client_id or your environment. Double-check the value in your onboarding email; if it matches and still 404s, route via Support.

Step 2: Confirm

const intentId = intent.id; // from Step 1

const res = await fetch(
  `${process.env.API_BASE_URL}/payment_intents/${intentId}/confirm`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ACCESS_TOKEN!}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ user_id: process.env.CUSTOMER_USER_ID }),
  },
);

const confirmed = await res.json();
console.log(confirmed.status, confirmed.paid_at);
export INTENT_ID="{the_id_from_step_1}"

curl -X POST "$API_BASE_URL/payment_intents/$INTENT_ID/confirm" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{ \"user_id\": \"$CUSTOMER_USER_ID\" }"
Response:
{
  "id": "{uuid}",
  "status": "paid",
  "paid_at": "2026-05-11T20:01:00Z"
}
The customer’s SPENDING wallet decreases by 1 FLY. The merchant’s wallet increases by 1 FLY. The customer gets a receipt email from flynet@blackbird.xyz.
Chef’s warning - If the customer does not hold enough FLY, this call returns 400 payment0030. Pre-check the customer’s SPENDING wallet balance for a smoother UX.

Step 3: Verify

const res = await fetch(
  `${process.env.API_BASE_URL}/payment_intents/${intentId}`,
  { headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN!}` } },
);

const verified = await res.json();
console.log(verified.status, verified.paid_at);
curl "$API_BASE_URL/payment_intents/$INTENT_ID" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
You should see status: "paid" and a populated paid_at.

Step 4: Refund

const res = await fetch(
  `${process.env.API_BASE_URL}/payment_intents/${intentId}/refund`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN!}` },
  },
);

const refunded = await res.json();
console.log(refunded.status); // "refunded"
curl -X POST "$API_BASE_URL/payment_intents/$INTENT_ID/refund" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
The 1 FLY returns to the customer. Status becomes refunded. Refunds are full-only in v1.

What’s next

  • Read Payments for the idempotency contract.
  • Handle insufficient FLY in your UI before calling confirm; see payment0030.
  • List merchant intents with GET /flynet/v1/payment_intents?payee_account_balance_id={uuid}.