# Ultraner with Next.js

The App Router splits server and client in a way that decides where your API key may live, and route handlers parse bodies differently from Express.

- Language: TypeScript
- Client: @ultraner/sdk (https://github.com/Ultraner/ultraner-node)
- Human version of this page: https://ultraner.com/frameworks/nextjs
- Live in 14 markets: Benin, Cameroon, DR Congo, Gabon, Ivory Coast, Kenya, Mozambique, Republic of Congo, Rwanda, Senegal, Sierra Leone, Tanzania, Uganda and Zambia

## The mistake this framework invites

In a Route Handler, `await req.json()` consumes the body and re-serialising it will never reproduce the bytes that were signed. Use `await req.text()` for the webhook, then parse after verifying.

## Install

```bash
npm install @ultraner/sdk
```

## Charge a mobile money wallet

```
// app/api/pay/route.ts  —  a Route Handler, so the key stays server-side.
import { Ultraner } from '@ultraner/sdk';
import { randomUUID } from 'node:crypto';

const ultraner = new Ultraner(process.env.ULTRANER_API_KEY!);

export async function POST(req: Request) {
  const { phone, amount } = await req.json();

  const payment = await ultraner.payments.createMnoCharge({
    amount,                        // whole shillings: TZS has no minor unit
    currency: 'TZS',
    provider: 'Vodacom',           // the provider code, not the brand name
    account_number: phone,         // full international form, no plus
  }, { idempotencyKey: randomUUID() });

  // The prompt is on its way to the handset. Nothing has been paid yet.
  return Response.json({ reference: payment.reference, status: payment.status });
}
```

## Verify a webhook

Where framework differences actually bite: the signature is computed over
the raw body, so any middleware that parses JSON before you read it will
break verification.

```
// app/api/webhooks/ultraner/route.ts
import crypto from 'node:crypto';

export async function POST(req: Request) {
  // Raw text, not .json(). Re-serialising changes key order and whitespace,
  // and the signature will never match.
  const raw = await req.text();
  const signature = req.headers.get('X-Ultraner-Signature') ?? '';

  const expected = crypto
    .createHmac('sha256', process.env.ULTRANER_WEBHOOK_SECRET!)
    .update(raw)
    .digest('hex');

  // Constant-time: a plain === leaks the signature one byte at a time.
  const valid = signature.length === expected.length && crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex'),
  );
  if (!valid) return new Response('bad signature', { status: 400 });

  const event = JSON.parse(raw);
  // Acknowledge fast. Slow handlers get retried, and retries mean you will
  // see the same event id twice, so key your work on it.
  void handle(event);
  return new Response(null, { status: 200 });
}
```

## Things that are true regardless of framework

- **Amounts are whole units in most African currencies.** 5000 TZS is five
  thousand shillings, not fifty. Read `decimals` from
  https://ultraner.com/ai/context/currencies.json rather than assuming cents.
- **A charge is asynchronous.** A 200 means the prompt reached the payer's
  handset, not that they paid. Poll
  `GET /v1/payments/express/status/{transaction_id}` or wait for the webhook.
- **A charge takes `provider`; a payout takes `network`.** Both take
  `account_number`, never `accountNumber`. Unknown keys are stripped
  rather than rejected, so a wrong name does not error: the recipient
  disappears and the call fails as a missing required field.
- **Paying money out needs `X-Signature-Key` and `Idempotency-Key`.**

## More

- Every framework: https://ultraner.com/frameworks
- API reference: https://ultraner.com/docs
- The whole reference as Markdown: https://ultraner.com/docs.md
- OpenAPI: https://ultraner.com/openapi.json
- Agent skills: https://github.com/Ultraner/ultraner-skills
- Machine-readable index of all of this: https://ultraner.com/ai/context/frameworks.json
