결제 위젯
앱이 Stripe 양식을 직접 보여줄 수 없을 때, 결제하거나 카드를 추가할 수 있는 안전한 Atli 페이지로 사용자를 보내세요.
언제 쓰나요
AI 어시스턴트와 MCP 클라이언트: 채팅으로 사람과 대화하며, 카드 양식을 둘 자체 화면이 없습니다.
흐름 밖에서 결제: 예를 들어 Telegram이나 WhatsApp의 봇, 또는 다른 기기에서 하거나 나중에 마무리하는 경우입니다.
Stripe SDK를 불러올 수 없는 그 밖의 클라이언트: 예를 들어 터미널이나 음성 앱입니다.

setup_intent
사용자는 지금 결제하지 않고 카드를 계정에 저장하며, 이후 클라이언트가 그 카드로 구매할 수 있습니다.
카드 추가 시작하기
POST /user/payment-methods/setup-intent는client_secret옆에 세 개의 필드를 함께 응답합니다.그룹의 카드에는 같은 방식으로
POST /groups/:groupId/cards/setup과POST /groups/:groupId/cards/confirm을 사용하세요. 이들의 응답에서는 ID를setup_intent_id라고 부릅니다.curl -X POST https://api.heyatli.com/user/payment-methods/setup-intent \ -H 'Authorization: Bearer YOUR_TOKEN' { "id": "seti_…", "client_secret": "seti_…_secret_…", "sealed_secret": "eyJhbGciOiJFQ0RILUVTIi…", "key_id": "QvWE2gHWYExKj2Yc…", "seal_signature": "c2lnbmF0dXJl…", "options": "{\"lang\":\"en\",\"message\":\"Plan for Australia\"}", "options_signature": "b3B0aW9ucw…" }상대에게 링크 보내기
세 값을 결제 페이지 링크에 넣고
type을setup_intent로 설정하세요. 사용자에게는 금액 없이 Stripe 양식만 보입니다.const link = new URL('https://heyatli.com/pay'); link.search = new URLSearchParams({ type: 'setup_intent', sealed_secret: response.sealed_secret, key_id: response.key_id, seal_signature: response.seal_signature, return_id: response.id, // Only when the response has them, and always together. options goes in // base64url-encoded (Buffer in Node; TextEncoder and btoa in a browser). ...(response.options && { options: Buffer.from(response.options).toString('base64url'), options_signature: response.options_signature, }), }).toString();카드 저장하기
사용자가 마치면
POST /user/payment-methods/confirm이 카드를 계정에 저장합니다. 그 뒤GET /user/payment-methods로 목록을 볼 수 있습니다.curl https://api.heyatli.com/user/payment-methods/confirm \ -H 'Authorization: Bearer YOUR_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "setup_intent_id": "seti_…" }'
링크에 들어가는 것
| 매개변수 | 필수 | 역할 |
|---|---|---|
| type | 예 |
|
| sealed_secret | 예 | 응답에서 받은 값을 받은 그대로 전달합니다. |
| key_id | 예 | 응답에서 받은 값입니다. 시크릿을 봉인한 키를 가리킵니다. |
| seal_signature | 예 | 응답에서 받은 값입니다. 페이지가 이 값을 확인하고, 없거나 일치하지 않는 링크는 거부합니다. |
| options | 아니요 | 응답의 |
| options_signature | 아니요 | 응답의 값을 바꾸지 않고 |
| lang | 아니요 | 페이지 언어로, 이 사이트의 15개 언어 중 하나입니다. 없으면 그 사람이 heyatli.com에서 고른 언어, 그다음 브라우저 언어를 씁니다. |
| theme | 아니요 |
|
| brand | 아니요 |
|
| redirect_success_url | 아니요 | 이후 사용자를 보낼 곳입니다. heyatli.com 또는 그 하위 도메인의 |
| redirect_cancel_url | 아니요 | 이후 사용자를 보낼 곳입니다. heyatli.com 또는 그 하위 도메인의 |
| redirect_failure_url | 아니요 | 이후 사용자를 보낼 곳입니다. heyatli.com 또는 그 하위 도메인의 |
| return_id | 아니요 | 리디렉션에 덧붙일 사용자만의 참조 값입니다. 영문자, 숫자, |
링크 다루는 법
값을 바꾸지 말고 그대로 전달하세요. 인코딩은
URLSearchParams가 처리합니다.페이지 옵션도 전달하세요. 응답에
options와options_signature가 있으면 둘 다 추가하세요.options는 base64url로 인코딩하고,options_signature는 받은 그대로입니다. 인코딩하기 전에 JSON을 바꾸지 마세요.한 환경 안에서만 사용하세요. Sandbox 값은
staging.heyatli.com/pay에서만, Production 값은heyatli.com/pay에서만 열립니다.비밀번호처럼 다루세요. 계정 소유자에게만 보내고 로그에는 남기지 마세요.
주문은 한 번에 하나입니다. 새로 구매하면 대기 중인 이전 주문과 그 링크가 취소되므로 링크를 저장하지 마세요.
세 필드가 없으면 다시 호출하세요. 암호화되지 않은
client_secret을 링크에 넣으면 안 됩니다.리디렉션은 아무것도 증명하지 않습니다. 마지막 단계의 확인만 믿으세요.
암호화와 서명의 작동 방식
링크를 보내는 데는 이 내용이 필요하지 않습니다. 어떤 일이 일어나는지 보고 서버가 반환한 내용을 확인할 수 있도록 적어 둔 것입니다. 둘 다 공개 키를 사용하며, 선택한 환경의 주소는 다음과 같습니다.
- 결제 페이지
- https://heyatli.com/pay
암호화
sealed_secret은 client_secret을 간결한 JWE로 만든 것으로, URL에 넣어도 안전한 형식입니다. 사이트의 X25519 공개 키로 암호화되므로 사이트의 서버만 읽을 수 있습니다. key_id는 그 키의 id이며 JWE의 kid와 일치합니다.
- alg
- ECDH-ES
- enc
- A256GCM
- kid
- key_id
- Key
- JWK: kty OKP, crv X25519, x = public_key
API가 대신 처리하며, 목록의 첫 번째 키를 사용합니다. 코드로는 다음과 같습니다.
import { CompactEncrypt, importJWK } from 'jose';
const { keys: [key] } = await (await fetch('https://heyatli.com/api/keys')).json();
const publicKey = await importJWK(
{ kty: 'OKP', crv: 'X25519', x: key.public_key },
'ECDH-ES'
);
const sealedSecret = await new CompactEncrypt(new TextEncoder().encode(clientSecret))
.setProtectedHeader({ alg: 'ECDH-ES', enc: 'A256GCM', kid: key.id })
.encrypt(publicKey);서명
누구나 암호화할 수 있으므로, 봉인된 값만으로는 누가 만들었는지 알 수 없습니다. 그래서 API는 자신만 가진 Ed25519 개인 키로 서명합니다. 서명 대상 메시지는 아래 세 줄을 줄바꿈으로 이은 UTF-8 바이트이며, 끝에는 줄바꿈이 없습니다.
atli-seal-v1
<key_id>
<sealed_secret>API는 외부로 나가지 않는 개인 키로 다음과 같이 서명을 만듭니다.
// What the API does. Only it holds the private key.
const message = new TextEncoder().encode(
`atli-seal-v1\n${keyId}\n${sealedSecret}`
);
const signature = await crypto.subtle.sign('Ed25519', privateKey, message);
const sealSignature = Buffer.from(signature).toString('base64url');seal_signature는 64바이트 원시 서명을 패딩 없는 base64url로 나타낸 것으로, 86자입니다. 환경마다 자체 키로 서명하며, 키를 교체하는 동안에는 목록에 두 개가 있을 수 있습니다.
결제 페이지는 무엇이든 열기 전에 이것을 확인하고, 없거나 일치하지 않는 링크는 거부합니다. 서버가 반환한 내용을 직접 확인하려면 그 메시지를 다시 만들고 서명 키로 서명을 검증하세요.
// In Node. sealedSecret, keyId and sealSignature come from the API response.
const { keys } = await (await fetch('https://api.heyatli.com/keys')).json();
const base64url = (value) => Uint8Array.from(Buffer.from(value, 'base64url'));
const message = new TextEncoder().encode(
`atli-seal-v1\n${keyId}\n${sealedSecret}`
);
// The list can hold two keys during a rotation: one match is enough.
const checks = keys.map(async (key) =>
crypto.subtle.verify(
'Ed25519',
await crypto.subtle.importKey('raw', base64url(key.public_key), 'Ed25519', false, ['verify']),
base64url(sealSignature),
message
)
);
const valid = (await Promise.all(checks)).some(Boolean);sealed_secret이나 key_id를 바꾸면 서명이 깨집니다. 서명은 type, 리디렉션, 나머지 매개변수를 다루지 않으며, 링크를 연 사람이 계정의 주인임을 보여 주지도 않습니다. 서명된 링크는 Stripe가 시크릿을 받아 주는 동안 계속 작동합니다.
페이지 옵션
API는 options를 JSON 텍스트로 반환합니다. 최대 300자의 일반 텍스트인 message와 메시지의 언어인 lang이 들어 있습니다. 키가 더 생길 수 있으며, 페이지는 모르는 키를 무시합니다. 메시지는 API 전체와 마찬가지로 요청 언어를 따릅니다: X-Locale, Accept-Language 또는 ?lang=. 링크에는 base64url로 들어가므로 이스케이프가 필요 없고, 어떤 언어로든 짧게 유지됩니다. 옵션은 부가 기능입니다. 없거나 검증을 통과하지 못해도 페이지는 옵션 없이 결제를 받습니다.
// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}options_signature는 같은 키로 다음 네 줄에 대해 만들어지며, <options>는 API가 반환한 그대로의 JSON입니다. 페이지는 링크의 값을 디코딩해 이를 확인합니다. 봉인된 시크릿이 포함되므로, 다른 링크로 옮긴 옵션은 통과하지 못합니다:
atli-options-v1
<key_id>
<sealed_secret>
<options>