Tempo Passkey (P-256 / WebAuthn) Integration

Accept Tempo root-passkey wallets for EVM credits redemption on the Tempo chain.

Overview

Tempo passkey users control a Tempo account with a P-256 / WebAuthn passkey. The passkey private key stays in the user’s authenticator/keychain, and the Tempo account address is derived from the passkey public key. Coinflow’s Tempo credits-redemption path accepts root WebAuthn passkey signatures for credits-only redemptions, so users do not need a separate MetaMask-style secp256k1 wallet for this flow.

This page covers what you need to do on the merchant side to wire a Tempo passkey wallet into <CoinflowPurchase>. The recommended integration uses Tempo’s wagmi webAuthn connector and a production key manager as described in Tempo’s embed-passkeys guide. Coinflow does not create or store passkeys for the merchant; your app owns the Tempo registration/sign-in UI and passes a compatible EthWallet adapter into Coinflow.

If you are also settling to a merchant contract on Tempo, your contract must be whitelisted first. See Whitelist Your Contracts.

What’s supported

Tempo checkout does not support partial Credits + USDC top-up. The iframe hides the USDC toggle on Tempo, and direct API callers get a 400 response for usdcAmount > 0. Use a full-credits payment instead.

FlowSupported on TempoNotes
Root passkey credits-only redemptionIntegrated through Tempo’s webAuthn connector and a standard Coinflow EthWallet adapter.
Credits + USDC top-up (partial payment)Not supported with passkey wallets. The iframe hides the USDC toggle on Tempo, and direct API calls with usdcAmount > 0 return 400.
Standard EVM wallet on TempoWorks with any EIP-1193 wallet the same as on other EVM chains — the passkey integration is separate and optional.
Tempo access-key walletsNot part of this integration. Only root passkey sessions are accepted.

How it works

  • Your app — hosts the registration / sign-in UI, manages the Tempo passkey session via wagmi, and exposes a standard EthWallet adapter to <CoinflowPurchase>. The adapter routes the signing request that Coinflow sends through signMessage to the Tempo connector — no other changes to how you embed Coinflow are needed.
  • Coinflow — verifies the passkey signature, submits the redemption transaction on your behalf, and returns the result to your app. Passkey wallets work the same way secp256k1 wallets do from your integration’s point of view.
  • Networks — Coinflow supports Tempo mainnet and the Moderato testnet. No extra configuration is required on your side when switching between them beyond the wagmi chain id.

Merchant integration

1. Configure the Tempo webAuthn connector in your wagmi config

Register Tempo’s WebAuthn connector alongside your existing EVM connectors. Use a remote key manager in production. Local storage is acceptable only for local demos because it stores the credential/public-key mapping in the browser; the passkey key material remains in the authenticator, but clearing storage or switching devices breaks lookup.

KeyManager.localStorage() is demo-only — ship a server-backed KeyManager.http(url) before production. The snippet below disables the connector entirely when VITE_TEMPO_KEY_MANAGER_URL is missing in a production build so you don’t silently fall back to browser storage.

1// ContextWrapper.tsx
2import {KeyManager, webAuthn} from '@wagmi/core/tempo';
3import {createConfig, http, WagmiProvider} from 'wagmi';
4import {
5 tempo,
6 tempoModerato /* ...plus your other chains */,
7} from 'wagmi/chains';
8import {injected, walletConnect} from 'wagmi/connectors';
9
10const tempoKeyManagerUrl = import.meta.env.VITE_TEMPO_KEY_MANAGER_URL?.trim();
11
12function createTempoWebAuthnConnector() {
13 if (tempoKeyManagerUrl) {
14 return webAuthn({
15 keyManager: KeyManager.http(tempoKeyManagerUrl),
16 createOptions: {label: 'My Merchant - Tempo passkey'},
17 });
18 }
19
20 if (import.meta.env.PROD) {
21 // Do not silently fall back to browser-local key lookup in production.
22 // Disable passkeys or fail startup until VITE_TEMPO_KEY_MANAGER_URL is set.
23 return null;
24 }
25
26 return webAuthn({
27 // eslint-disable-next-line @typescript-eslint/no-deprecated
28 keyManager: KeyManager.localStorage(),
29 createOptions: {label: 'My Merchant - Tempo passkey'},
30 });
31}
32
33const tempoWebAuthnConnector = createTempoWebAuthnConnector();
34
35const config = createConfig({
36 chains: [tempo, tempoModerato /* ...plus your other chains */],
37 connectors: tempoWebAuthnConnector
38 ? [tempoWebAuthnConnector, injected(), walletConnect({projectId})]
39 : [injected(), walletConnect({projectId})],
40 transports: {
41 [tempo.id]: http(),
42 [tempoModerato.id]: http(),
43 // ...other chains
44 },
45});
46
47export const TEMPO_WEBAUTHN_CONNECTOR_TYPE = webAuthn.type; // 'webAuthn'
48
49export function ContextWrapper({children}) {
50 return (
51 <WagmiProvider config={config}>
52 {/* ...QueryClientProvider, your app tree... */}
53 {children}
54 </WagmiProvider>
55 );
56}

2. Drive sign-up / sign-in through wagmi

The connector accepts a capabilities argument on connect() that selects between registering a new passkey (sign-up) and authenticating with an existing one (sign-in). Pass the Tempo chain id when connecting so the connector session is created for the same Tempo network that Coinflow will use.

1import {tempo, tempoModerato} from 'wagmi/chains';
2import {useConnect, useConnections, useDisconnect} from 'wagmi';
3import {TEMPO_WEBAUTHN_CONNECTOR_TYPE} from './ContextWrapper';
4
5const tempoChainId = import.meta.env.PROD ? tempo.id : tempoModerato.id;
6
7function TempoPasskeyGate({children}) {
8 const connections = useConnections();
9 const {connect, connectors, isPending, error} = useConnect();
10 const {disconnect} = useDisconnect();
11
12 const tempoAccount = connections.find(
13 c => c.connector.type === TEMPO_WEBAUTHN_CONNECTOR_TYPE
14 );
15 const tempoConnector = connectors.find(
16 c => c.type === TEMPO_WEBAUTHN_CONNECTOR_TYPE
17 );
18
19 // wagmi's public `ConnectVariables` type does not expose `capabilities`,
20 // but the Tempo `webAuthn` connector consumes it at runtime. Scope the
21 // cast to `Parameters<typeof connect>[0]` so a future wagmi rename still
22 // fails type-checking at this call site.
23 const connectWith = (type: 'sign-up' | 'sign-in') => {
24 if (!tempoConnector) return;
25 const variables = {
26 connector: tempoConnector,
27 chainId: tempoChainId,
28 capabilities: {type},
29 } as Parameters<typeof connect>[0];
30 connect(variables);
31 };
32
33 if (!tempoAccount) {
34 return (
35 <div>
36 <button disabled={isPending} onClick={() => connectWith('sign-up')}>
37 Register new passkey
38 </button>
39 <button disabled={isPending} onClick={() => connectWith('sign-in')}>
40 Sign in with existing passkey
41 </button>
42 {error && <p>{error.message}</p>}
43 </div>
44 );
45 }
46
47 return (
48 <>
49 <div>
50 Signed in as <code>{tempoAccount.accounts[0]}</code>
51 <button onClick={() => disconnect({connector: tempoAccount.connector})}>
52 Sign out
53 </button>
54 </div>
55 {children(tempoAccount)}
56 </>
57 );
58}

3. Adapt the wagmi session to an EthWallet for <CoinflowPurchase>

Coinflow’s iframe passes EIP-712 typed data into signMessage as a JSON-stringified object, the same shape secp256k1 merchants already handle. Parse the string, detect the EIP-712 shape, and route typed data through wagmi’s useSignTypedData (which invokes the Tempo connector under the hood). Let typed-data errors surface — do not retry as personal_sign.

1import {useCallback} from 'react';
2import {useConnections, useSignMessage, useSignTypedData} from 'wagmi';
3import type {Hex} from 'viem';
4import type {EthWallet} from '@coinflowlabs/react';
5import {TEMPO_WEBAUTHN_CONNECTOR_TYPE} from './ContextWrapper';
6
7type Eip712Payload = {
8 domain: Record<string, unknown>;
9 types: Record<string, readonly {name: string; type: string}[]>;
10 primaryType: string;
11 message: Record<string, unknown>;
12};
13
14function parseJsonMessage(message: string): unknown {
15 try {
16 return JSON.parse(message) as unknown;
17 } catch {
18 return undefined;
19 }
20}
21
22function isEip712Payload(value: unknown): value is Eip712Payload {
23 if (value === null || typeof value !== 'object') return false;
24 const v = value as Record<string, unknown>;
25 return (
26 typeof v.domain === 'object' &&
27 v.domain !== null &&
28 typeof v.types === 'object' &&
29 v.types !== null &&
30 typeof v.primaryType === 'string' &&
31 typeof v.message === 'object' &&
32 v.message !== null
33 );
34}
35
36export function useTempoPasskeyWallet(): EthWallet | undefined {
37 const connections = useConnections();
38 const {signMessageAsync} = useSignMessage();
39 const {signTypedDataAsync} = useSignTypedData();
40
41 const tempoAccount = connections.find(
42 c => c.connector.type === TEMPO_WEBAUTHN_CONNECTOR_TYPE
43 );
44 const address = tempoAccount?.accounts[0];
45
46 const signMessage = useCallback(
47 async (message: string): Promise<Hex> => {
48 if (!tempoAccount || !address) {
49 throw new Error('Tempo passkey wallet not connected - sign in first.');
50 }
51
52 const parsed = parseJsonMessage(message);
53 if (isEip712Payload(parsed)) {
54 return signTypedDataAsync({
55 ...parsed,
56 connector: tempoAccount.connector,
57 } as Parameters<typeof signTypedDataAsync>[0]);
58 }
59
60 return signMessageAsync({
61 message,
62 connector: tempoAccount.connector,
63 });
64 },
65 [tempoAccount, address, signMessageAsync, signTypedDataAsync]
66 );
67
68 if (!tempoAccount || !address) return undefined;
69
70 return {
71 address,
72 signMessage,
73 sendTransaction: () =>
74 Promise.reject(
75 new Error(
76 'Tempo passkey redemption does not require sendTransaction — Coinflow submits the redemption on your behalf.'
77 )
78 ),
79 };
80}

Compose the gate, adapter hook, and <CoinflowPurchase>:

1function TempoPurchaseContent() {
2 const wallet = useTempoPasskeyWallet();
3 if (!wallet) return null;
4
5 return (
6 <CoinflowPurchase
7 wallet={wallet}
8 blockchain={'tempo'}
9 partialUsdcChecked={false}
10 /* ...other props... */
11 />
12 );
13}
14
15function TempoPurchase() {
16 return <TempoPasskeyGate>{() => <TempoPurchaseContent />}</TempoPasskeyGate>;
17}

4. Hide the USDC top-up toggle on Tempo

Partial Credits + USDC is not supported on Tempo. Pass partialUsdcChecked={false} when blockchain === 'tempo' so the option is not offered to users:

1<CoinflowPurchase
2 wallet={wallet}
3 blockchain={chain}
4 partialUsdcChecked={chain !== 'tempo'}
5 /* ...other props... */
6/>

If a direct API caller bypasses the UI, /redeem/evm/creditsAuthMsg returns 400 with:

Partial credits + USDC payments are not supported on Tempo. Please use a full-credits payment instead.

If a caller reaches /redeem/evm/sendGaslessTx with a Tempo passkey signature and customerUsdcAuthData, Coinflow rejects the request — root passkey redemptions are credits-only.

Login signatures

The same signMessage adapter you wired in step 3 also handles Coinflow’s login challenge — no extra client-side work is required for users to sign in with their Tempo passkey.

Errors your users may see

The Coinflow iframe surfaces these messages to the user during the Tempo passkey flow:

When it happensWhat the user sees
Checkout is attempted from a non-passkey wallet on a passkey-only flowA passkey-compatible wallet is required to complete this redemption.
The app or API attempts a Credits + USDC top-up on TempoPartial credit + USDC payments are not supported on Tempo. Please pay the full amount with credits.
The passkey signature cannot be verifiedInvalid credits auth signature.

Operational notes

  • Production key storage. Use KeyManager.http(url) pointed at a server-side key store for production. KeyManager.localStorage() is demo-only because it stores the credential/public-key mapping in the browser, so the app cannot recover the mapping after browser storage is cleared or the user switches devices.
  • Only required for Tempo passkey users. If a merchant never needs root passkey support, this integration is optional — standard EVM wallets on Tempo continue to work through Coinflow’s normal EVM flow.