Base URL: https://api.formfriend.xyz
Format: All request/response bodies are JSON (Content-Type: application/json) except the contract upload which is multipart/form-data.
Auth: Bearer token in the Authorization header — Authorization: Bearer <accessToken>. Token is a Cognito JWT obtained from /auth/login.
Errors: All errors return RFC 9457 Problem Detail JSON:
{ "title": "...", "detail": "...", "status": 400 }Registration is 2-step. Both steps must complete or the user has no DB record and /users/me returns 404.
Step 1: POST /users → triggers Cognito to email a 6-digit code
Step 2: POST /users/confirm → submits the code, creates the DB record
Step 3: POST /auth/login → returns tokens
No auth required.
Request:
{ "email": "jsmith@university.edu", "password": "..." }Response 200:
{
"accessToken": "eyJ...",
"idToken": "eyJ...",
"refreshToken": "eyJ..."
}Store
accessToken— it is used as the Bearer token for all authenticated requests.refreshTokencan be used to obtain a newaccessTokenwhen it expires (Cognito handles this).
Errors: 401 invalid credentials.
No auth required.
Request:
{
"name": "Jane Smith",
"email": "jsmith@university.edu",
"password": "SecurePass1!",
"phoneNumber": "+12025551234",
"nickname": "jane",
"preferredUsername": "jsmith",
"personalEmail": "jane@gmail.com"
}
phoneNumbermust include country code (e.g.+1).nickname,preferredUsername, andpersonalEmailare optional.
Response 202: Empty body. A 6-digit code is sent to the user's email.
Errors: 400 duplicate user or unrecognized school domain.
No auth required.
Request:
{ "email": "jsmith@university.edu", "code": "123456" }Response 201: Returns the created user (same shape as UserResponse below).
Errors: 400 invalid or expired code.
Auth required.
Response 200:
{
"id": 42,
"name": "Jane Smith",
"email": "jsmith@university.edu",
"schoolId": 7,
"schoolCode": "MIT",
"schoolName": "Massachusetts Institute of Technology"
}Errors: 401 missing/invalid token, 404 token is valid but no DB record exists (registration was not completed — redirect to registration).
Auth required.
Response 200: Array of UserResponse objects (same shape as above).
Auth required.
Response 200: UserResponse. Errors: 404.
Auth required.
Request (all fields optional):
{
"name": "Jane Doe",
"email": "jdoe@university.edu",
"personalEmail": "jane@gmail.com",
"schoolId": 3
}Response 200: Updated UserResponse.
Auth required.
Response 204: No body.
Auth required. Content-Type: multipart/form-data
Request: Form field file — must be a PDF (application/pdf).
Response 200: Flat array of all Textract blocks for the document. Block hierarchy (pages → lines → words, tables → cells) is encoded in the relationships field using Textract's native structure.
[
{
"id": "a1b2c3d4-...",
"blockType": "LINE",
"text": "This Agreement is entered into",
"page": 1,
"confidence": 99.1,
"boundingBox": { "left": 0.08, "top": 0.05, "width": 0.6, "height": 0.015 },
"polygon": [
{ "x": 0.08, "y": 0.05 },
{ "x": 0.68, "y": 0.05 },
{ "x": 0.68, "y": 0.065 },
{ "x": 0.08, "y": 0.065 }
],
"relationships": [
{ "type": "CHILD", "ids": ["e5f6...", "g7h8..."] }
],
"entityTypes": [],
"selectionStatus": null,
"textType": "PRINTED",
"rowIndex": null,
"columnIndex": null,
"rowSpan": null,
"columnSpan": null
},
...
]blockType values: PAGE, LINE, WORD, TABLE, TABLE_TITLE, TABLE_FOOTER, CELL, MERGED_CELL, SELECTION_ELEMENT, QUERY, QUERY_RESULT, SIGNATURE, KEY_VALUE_SET
Bounding box: All coordinates are normalized 0–1 relative to page dimensions. boundingBox and polygon may be null for PAGE-level blocks.
Navigating structure:
- A
PAGEblock'sCHILDrelationships list allLINEandTABLEblock IDs on that page. - A
LINEblock'sCHILDrelationships list itsWORDblock IDs (reading order). - A
TABLEblock'sCHILDrelationships list itsCELLblock IDs. CELLblocks includerowIndex,columnIndex,rowSpan,columnSpanfor table layout reconstruction.SELECTION_ELEMENTblocks carryselectionStatus:SELECTEDorNOT_SELECTED.
Response 200 shape:
{
"uploadId": 12,
"filename": "lease-agreement.pdf",
"lines": [ [ { ...WordBlock }, ... ], ... ]
}Errors: 400 empty file, 415 not a PDF, 401 unauthenticated.
Auth required.
Response 200:
[
{ "id": 12, "filename": "lease-agreement.pdf", "createdAt": "2026-03-28T14:22:00Z" },
{ "id": 11, "filename": "nda.pdf", "createdAt": "2026-03-27T09:10:00Z" }
]Results are ordered newest first.
Auth required. Only returns a URL for uploads belonging to the current user.
Response 200:
{ "url": "https://s3.amazonaws.com/bucket/uploads/...?X-Amz-Signature=..." }URL is valid for 15 minutes. Use it directly in an <iframe>, <embed>, or WKWebView. Requesting a URL for an upload that belongs to another user returns 404.
https://api.formfriend.xyz
│
├── POST /auth/login # get tokens (no auth)
│
├── POST /users # step 1: initiate registration (no auth)
├── POST /users/confirm # step 2: verify email code (no auth)
├── GET /users # list all users (auth)
├── GET /users/me # current user from JWT (auth)
├── GET /users/{id} # user by id (auth)
├── PATCH /users/{id} # update user (auth)
└── DELETE /users/{id} # delete user (auth)
│
├── POST /contracts/parse # upload PDF → { uploadId, filename, lines } (auth, multipart)
├── GET /contracts # list past uploads, newest first (auth)
└── GET /contracts/{id}/file # presigned S3 URL for the PDF, 15-min TTL (auth)
- Store
accessTokeninKeychain, notUserDefaults. - On any
401, clear the stored token and route the user back to login. - On
GET /users/mereturning404, the account is incomplete — route to registration. - The contract parse endpoint can take several seconds (Textract is async on the backend); show a loading indicator and use a generous timeout (30–60s).
multipart/form-dataupload: set the part'sContent-Typetoapplication/pdfexplicitly or the server will reject with415.