Credentials and signing
A call proves who is making it in one of two ways: with an API key sent as a bearer token, or with a signature made by a private key only your institution holds. Both are created on the portal's API & Webhooks page.
API keys
Send a key as Authorization: Bearer <key>, and keep it as Your responsibilities says: in a secrets manager, never in code or email. GET /api/gap/v1/me answers any key with what it is, what it may do and when it expires.
The v2 key format At launch
sa_live_ for a live key or sa_test_ for a test key, then 30 base62 characters, then a 6-character base62 CRC-32: 44 characters in all. The checksum is the CRC-32 (the IEEE polynomial) of the prefix and body's bytes, written as base62 digits (0 to 9, A to Z, then a to z), most significant first and padded with zeros. It lets you, or a scanner looking for leaked secrets, tell a mistyped or truncated key from a real one without a call; it proves nothing about whether a key is genuine.
The portal shows a key by its first 12 characters: its prefix and the 4 characters after it. Use them to name a key to us; the whole key is shown only once, when it is created.
A key holds only the permissions named on it: referrals:write, referrals:read, referrals.contact:read, reporting:read, plans:read, plans:write, members:activation-link, audit:read. A seat with credentials:manage can change them. A call that needs one the key doesn't hold is answered 403 permission_denied.
Lifetimes and rolling
Every key has an expiry: a test key lasts at most 90 days. You can choose a shorter life when you create one.
At launchA v2 live key lasts at most 365 days. Roll a key before it expires: after you roll it, the old key keeps working for at most 7 days, and never past its own expiry, so you can deploy the new one without a gap. A key neither used nor created in 90 days is flagged idle.
Signed requests At launch
Instead of a key, your institution can register its own public key and sign each request with the private key, which never leaves your systems. Signatures follow RFC 9421 (HTTP Message Signatures) and RFC 9530 (Content-Digest), profile gap-v2, with ed25519 or ecdsa-p256-sha256. Every signature is 64 bytes.
Register and activate a credential
Register an Ed25519 or ECDSA P-256 public key on the portal's API & Webhooks page. The credential starts unactivated: until it is, every call it signs is answered 401 credential_not_activated. Activate it by signing POST /api/gap/v1/credentials/{id}/verify with it, its id in the path and an empty body. A public key can be active for one credential only. After a signed credential is replaced, the old one keeps working for at most 30 days, and never past its own expiry.
Sign a request
- For a POST or a PATCH, compute
Content-Digest:sha-256=:<base64 of the SHA-256 of the body's exact bytes>:, over the body's UTF-8 bytes as you send them. A POST or a PATCH must also send anIdempotency-Key: 1 to 255 visible ASCII characters (! to ~), required on POST and PATCH. - Take
created, the time now in Unix seconds, and a newnonce: 16 to 128 characters from[A-Za-z0-9_-]. A nonce is single-use for its credential, held until at least 331 seconds aftercreated. A retry needs a new nonce too, and keeps the same Idempotency-Key. A repeated nonce is answered 409 signature_replay. - List the covered components, in this order. POST and PATCH:
"@method" "@target-uri" "content-digest" "idempotency-key" "x-sa-environment". GET and HEAD:"@method" "@target-uri" "x-sa-environment", or"@method" "@target-uri" "idempotency-key" "x-sa-environment"when you send an Idempotency-Key. No other method is signed. - Write the signature parameters: the components in parentheses, then
;created=<created>;nonce="<nonce>";keyid="<credential id>";alg="<algorithm>";tag="gap-v2", in that order. - Build the signature base: one line for each component,
"<name>": <value>, then"@signature-params": <the parameters>, joined by single line feeds with none at the end. The value of@methodis the method in capitals, each header's is its value as you send it, and@target-uriis built as below. - Sign the base's UTF-8 bytes with the private key.
- Send
Signature-Input: sa=<the parameters>,Signature: sa=:<base64 of the signature>:andX-SA-Environment:TESTfor a test credential,PRODUCTIONfor a live one. Add Content-Digest and Idempotency-Key where you computed them. A signed request carries no Authorization header.
The server accepts created at most 300 seconds old and 30 seconds ahead of its clock; outside that, the call is answered 401 signature_expired. A signature that doesn't verify is answered 401 signature_invalid, and one that doesn't follow the profile 401 signature_profile_invalid.
At launchWhile you build your signer, sign a request with a test credential and send it to POST /api/gap/v1/signature-check: it answers with whether the signature verified and the signature base built from the request as received, to compare byte for byte with yours.
The target URI
https:// + the host you called, in lower case + the path and query of the request URL as a WHATWG URL parser serializes it (new URL() in JavaScript, which is how the server reads every request): never the request line as your client wrote it. Percent-encode each query value with RFC 3986's unreserved characters only (a ' is %27, and a + in a time's offset is %2B: a raw + in a query reads as a space), percent-encode non-ASCII as its UTF-8 bytes, and send no empty ? and no . or .. path segment; a percent-escape you send is kept as sent, the case of its hex digits included. The examples below show each.
| Sent | Signed | Why |
|---|---|---|
| https://gap.secondappraisal.com/api/gap/v1/referrals?updated_after=2026-10-01T00:00:00%2b02:00 | https://gap.secondappraisal.com/api/gap/v1/referrals?updated_after=2026-10-01T00:00:00%2b02:00 | A percent-escape is kept as sent, its case included. |
| https://gap.secondappraisal.com/api/gap/v1/referrals? | https://gap.secondappraisal.com/api/gap/v1/referrals | An empty query is dropped. |
| https://gap.secondappraisal.com/api/gap/v1/./referrals?limit=5 | https://gap.secondappraisal.com/api/gap/v1/referrals?limit=5 | Dot segments in the path are resolved. |
| https://gap.secondappraisal.com/api/gap/v1/plans/enrollments?status=active&limit=50#page2 | https://gap.secondappraisal.com/api/gap/v1/plans/enrollments?status=active&limit=50 | A fragment is never sent, so never signed. |
Encoding the signature
ed25519: The 64-byte Ed25519 signature (RFC 8032), base64 in the Signature header: sa=:<base64>:.ecdsa-p256-sha256: The 64-byte IEEE P1363 concatenation r||s (each 32 bytes, big-endian, left-padded with zeros), base64 in the Signature header: sa=:<base64>:. Never ASN.1 DER: anything but 64 bytes is refused. A 64-byte value is already r||s; anything else is DER to convert, which starts with 0x30 and is usually 70 to 72 bytes, sometimes fewer.- To convert a DER signature, take each INTEGER's value, drop a leading 0x00 byte, left-pad it with zeros to 32 bytes, and concatenate r then s. In Java (JDK 11 or later),
Signature.getInstance("SHA256withECDSAinP1363Format")signs r||s directly; in .NET,ECDsa.SignDatareturns r||s. This DER signature, whose r is shorter than 32 bytes, converts to the P-256 vector's signature below:
DER (69 bytes): MEMCH0kW4xzozge10RBMVPoh02LdH3rrW8ZXqauU5DhATNUCIGMnmhoRD+fk/dtTvGqgk+hPiGabLc6/vSfm0CkykjEj
r||s (64 bytes): AEkW4xzozge10RBMVPoh02LdH3rrW8ZXqauU5DhATNVjJ5oaEQ/n5P3bU7xqoJPoT4hmmy3Ov70n5tApMpIxIw==Test vectors
Verify-only vectors for the gap-v2 profile. For each request, build the signature base from the request and compare it with the one below byte for byte, then check the signature over the base's UTF-8 bytes with the credential's public key. Each credential has one mode, and every request it signs sends that mode's X-SA-Environment. The private keys were discarded: these keys sign nothing else, and the API refuses to register them.
The credentials
keyid: cmfixtureed25519testkey03
alg: ed25519
mode: test
jwk_thumbprint: OfJnSzpzqPNhnE1RYg1ZDJsRqu6bhlUtfuU7Cid8eOY
public_key_jwk:
{
"crv": "Ed25519",
"x": "3wI78RZhzhsVrRQjx2lrRCuFOKsgCOs8dsKGtpCQQqE",
"kty": "OKP"
}
public_key_pem:
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA3wI78RZhzhsVrRQjx2lrRCuFOKsgCOs8dsKGtpCQQqE=
-----END PUBLIC KEY-----keyid: cmfixturep256livekey00003
alg: ecdsa-p256-sha256
mode: live
jwk_thumbprint: vtUOD8TnmnfatT6h8StS_o4DJAlEiH0G4zUCD1fXCD0
public_key_jwk:
{
"kty": "EC",
"x": "5uF5IHy-H1h8phcoqLrdxVXgTv2r5ip1M3qJABB11tk",
"y": "gltJwBwEEner5LoNFqPabRSZZy062ZegXV8n3IWQWW8",
"crv": "P-256"
}
public_key_pem:
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5uF5IHy+H1h8phcoqLrdxVXgTv2r
5ip1M3qJABB11tmCW0nAHAQSd6vkug0Wo9ptFJlnLTrZl6BdXyfchZBZbw==
-----END PUBLIC KEY-----1. Ed25519: create a referral with a test credential, a body of multi-byte UTF-8
Signed by cmfixtureed25519testkey03 with ed25519.
POST https://gap.secondappraisal.com/api/gap/v1/referrals
content-type: application/json
content-digest: sha-256=:3Xp/FkNQ3GnTkOu2Qt9lWxF5G/bQCuAhfs8O/abBA8Y=:
idempotency-key: referral-2026-10-07-0001
x-sa-environment: TEST
signature-input: sa=("@method" "@target-uri" "content-digest" "idempotency-key" "x-sa-environment");created=1791331200;nonce="bm9uY2UtZWQyNTUxOS0wMDAx";keyid="cmfixtureed25519testkey03";alg="ed25519";tag="gap-v2"
signature: sa=:GteS90Yx6hUukuNe+RTwWgL/YORbPbKdirX0PeHRR7UkhXqh6bEEz10+HQY1C3vyzTcC0cTDeBwzpAJgsxe9Aw==:
{"borrower_first_name":"José","borrower_last_name":"Muñoz","borrower_email":"jose.munoz@example.test","external_ref":"LN-000123"}"@method": POST
"@target-uri": https://gap.secondappraisal.com/api/gap/v1/referrals
"content-digest": sha-256=:3Xp/FkNQ3GnTkOu2Qt9lWxF5G/bQCuAhfs8O/abBA8Y=:
"idempotency-key": referral-2026-10-07-0001
"x-sa-environment": TEST
"@signature-params": ("@method" "@target-uri" "content-digest" "idempotency-key" "x-sa-environment");created=1791331200;nonce="bm9uY2UtZWQyNTUxOS0wMDAx";keyid="cmfixtureed25519testkey03";alg="ed25519";tag="gap-v2"2. ECDSA P-256: change an enrollment with a live credential
Signed by cmfixturep256livekey00003 with ecdsa-p256-sha256.
PATCH https://secondappraisal.com/api/gap/v1/plans/enrollments/enr_fixture_0001
content-type: application/json
content-digest: sha-256=:p16uKSGPS7xK+T72ianza61ZLKHTPnCcpQLP4MZnATk=:
idempotency-key: enrollment-edit-0001
x-sa-environment: PRODUCTION
signature-input: sa=("@method" "@target-uri" "content-digest" "idempotency-key" "x-sa-environment");created=1791331230;nonce="bm9uY2UtcDI1Ni0wMDAwMDAwMg";keyid="cmfixturep256livekey00003";alg="ecdsa-p256-sha256";tag="gap-v2"
signature: sa=:1teg2CNsHAYN2jeSksfSbhRwDyoiEKUg/a48I8X9Joqq2VBmmDmhxdg+bQd3bVzBrtt4kwr1wpXB4MU/yer5xw==:
{"garaged_state":"TX"}"@method": PATCH
"@target-uri": https://secondappraisal.com/api/gap/v1/plans/enrollments/enr_fixture_0001
"content-digest": sha-256=:p16uKSGPS7xK+T72ianza61ZLKHTPnCcpQLP4MZnATk=:
"idempotency-key": enrollment-edit-0001
"x-sa-environment": PRODUCTION
"@signature-params": ("@method" "@target-uri" "content-digest" "idempotency-key" "x-sa-environment");created=1791331230;nonce="bm9uY2UtcDI1Ni0wMDAwMDAwMg";keyid="cmfixturep256livekey00003";alg="ecdsa-p256-sha256";tag="gap-v2"3. ECDSA P-256: list referrals with a live credential, a query, no Idempotency-Key
Signed by cmfixturep256livekey00003 with ecdsa-p256-sha256.
GET https://gap.secondappraisal.com/api/gap/v1/referrals?limit=25&status=submitted
x-sa-environment: PRODUCTION
signature-input: sa=("@method" "@target-uri" "x-sa-environment");created=1791331260;nonce="bm9uY2UtcDI1Ni0wMDAwMDAwMw";keyid="cmfixturep256livekey00003";alg="ecdsa-p256-sha256";tag="gap-v2"
signature: sa=:9w/De20ZBpTYxr1eGm7fxwpCTX8444l2WUWXkVZCe0q+7xldK/8dBOGfNMKjk91D+/EnJ3vRnj6COK3ChNew7g==:"@method": GET
"@target-uri": https://gap.secondappraisal.com/api/gap/v1/referrals?limit=25&status=submitted
"x-sa-environment": PRODUCTION
"@signature-params": ("@method" "@target-uri" "x-sa-environment");created=1791331260;nonce="bm9uY2UtcDI1Ni0wMDAwMDAwMw";keyid="cmfixturep256livekey00003";alg="ecdsa-p256-sha256";tag="gap-v2"4. Ed25519: GET /me with a test credential and an Idempotency-Key, which is then covered
Signed by cmfixtureed25519testkey03 with ed25519.
GET https://lenders.secondappraisal.com/api/gap/v1/me
idempotency-key: me-check-0001
x-sa-environment: TEST
signature-input: sa=("@method" "@target-uri" "idempotency-key" "x-sa-environment");created=1791331290;nonce="bm9uY2UtZWQyNTUxOS0wMDA0";keyid="cmfixtureed25519testkey03";alg="ed25519";tag="gap-v2"
signature: sa=:NxfGI9yW7ErJMfbXqSLrZtgsec+GvQ6jX8jyUPTYt3cnnMMnvci5K9CaOZHBS5pj5Nem2fQZP6qD/2VMilHUBw==:"@method": GET
"@target-uri": https://lenders.secondappraisal.com/api/gap/v1/me
"idempotency-key": me-check-0001
"x-sa-environment": TEST
"@signature-params": ("@method" "@target-uri" "idempotency-key" "x-sa-environment");created=1791331290;nonce="bm9uY2UtZWQyNTUxOS0wMDA0";keyid="cmfixtureed25519testkey03";alg="ed25519";tag="gap-v2"5. ECDSA P-256: the live credential's verification call that activates it, an empty body
Signed by cmfixturep256livekey00003 with ecdsa-p256-sha256.
POST https://secondappraisal.com/api/gap/v1/credentials/cmfixturep256livekey00003/verify
content-type: application/json
content-digest: sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:
idempotency-key: verify-0001
x-sa-environment: PRODUCTION
signature-input: sa=("@method" "@target-uri" "content-digest" "idempotency-key" "x-sa-environment");created=1791331320;nonce="bm9uY2UtcDI1Ni0wMDAwMDAwNQ";keyid="cmfixturep256livekey00003";alg="ecdsa-p256-sha256";tag="gap-v2"
signature: sa=:2kK58ernetGPxcURuympZc5xonhxsp9MGzRlCsDwuNW0U7LGzpPfsgT4entaaYbNywIr4n5V2K2gAG0Rlig9qg==:"@method": POST
"@target-uri": https://secondappraisal.com/api/gap/v1/credentials/cmfixturep256livekey00003/verify
"content-digest": sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:
"idempotency-key": verify-0001
"x-sa-environment": PRODUCTION
"@signature-params": ("@method" "@target-uri" "content-digest" "idempotency-key" "x-sa-environment");created=1791331320;nonce="bm9uY2UtcDI1Ni0wMDAwMDAwNQ";keyid="cmfixturep256livekey00003";alg="ecdsa-p256-sha256";tag="gap-v2"6. ECDSA P-256: a signature whose r or s begins with a zero byte, left-padded to 32 bytes
Signed by cmfixturep256livekey00003 with ecdsa-p256-sha256.
GET https://gap.secondappraisal.com/api/gap/v1/me
x-sa-environment: PRODUCTION
signature-input: sa=("@method" "@target-uri" "x-sa-environment");created=1791331350;nonce="bm9uY2UtcDI1Ni0wMDAwMDAwNg61";keyid="cmfixturep256livekey00003";alg="ecdsa-p256-sha256";tag="gap-v2"
signature: sa=:AEkW4xzozge10RBMVPoh02LdH3rrW8ZXqauU5DhATNVjJ5oaEQ/n5P3bU7xqoJPoT4hmmy3Ov70n5tApMpIxIw==:"@method": GET
"@target-uri": https://gap.secondappraisal.com/api/gap/v1/me
"x-sa-environment": PRODUCTION
"@signature-params": ("@method" "@target-uri" "x-sa-environment");created=1791331350;nonce="bm9uY2UtcDI1Ni0wMDAwMDAwNg61";keyid="cmfixturep256livekey00003";alg="ecdsa-p256-sha256";tag="gap-v2"7. Ed25519: list the referrals changed since a time, its offset's + sent and signed as %2B
Signed by cmfixtureed25519testkey03 with ed25519.
GET https://gap.secondappraisal.com/api/gap/v1/referrals?updated_after=2026-10-01T00:00:00%2B02:00
x-sa-environment: TEST
signature-input: sa=("@method" "@target-uri" "x-sa-environment");created=1791331380;nonce="bm9uY2UtZWQyNTUxOS0wMDA3";keyid="cmfixtureed25519testkey03";alg="ed25519";tag="gap-v2"
signature: sa=:WXVM/aD2uNYAkc51E4nrUFEaaBbYNm8FOrpCgxW0NCtAVmFa9gYqxXXt+2al4odN5o60huwvR9OceaQvdHEmCw==:"@method": GET
"@target-uri": https://gap.secondappraisal.com/api/gap/v1/referrals?updated_after=2026-10-01T00:00:00%2B02:00
"x-sa-environment": TEST
"@signature-params": ("@method" "@target-uri" "x-sa-environment");created=1791331380;nonce="bm9uY2UtZWQyNTUxOS0wMDA3";keyid="cmfixtureed25519testkey03";alg="ed25519";tag="gap-v2"