POST /api/v1/files
Attach one file to a record. Requires the write:files scope (or * for full access).
There are three ways to send the bytes. Pick by where the file already is and how big it is:
| Method | Best for | Limit |
|---|---|---|
| Multipart upload | A file on disk | 10 MB |
| JSON: base64 or URL | Bytes already in memory, or a file already on the web | 10 MB |
| Two-step upload | Anything from 10 MB up to 50 MB | 50 MB |
Every method returns 201 with the new file in the same shape:
{
"file": {
"id": "cmq3file0alk34567890",
"entity": "CONTACT",
"recordId": "cmq2cont0alk34567891",
"kind": "attachment",
"fileName": "signed-nda.pdf",
"contentType": "application/pdf",
"size": 184320,
"createdAt": "2026-09-27T14:02:00.000Z"
}
}Offerings and campaigns have their own rules about which files they accept and when. Read Rules for each record type before you attach to either.
Multipart upload
Send multipart/form-data with these fields:
| Field | Required | Description |
|---|---|---|
file | Yes | The file |
entity | Yes | The record type, e.g. contact or DEAL |
recordId | Yes | The record's agentlyleads ID, or its externalId where it has one |
fileName | No | Overrides the name the file part was sent with |
curl -s -X POST https://agentlyleads.com/api/v1/files \
-H "Authorization: Bearer $AL_KEY" \
-F "entity=contact" \
-F "recordId=hs_contact_123" \
-F "file=@./signed-nda.pdf;type=application/pdf"The file's MIME type comes from the part's own Content-Type. curl guesses it from the extension unless you set ;type= yourself, as above.
JSON: base64 or a URL
Send application/json with entity, recordId, fileName and exactly one of contentBase64 or url.
| Field | Required | Description |
|---|---|---|
entity | Yes | The record type |
recordId | Yes | The record's agentlyleads ID, or its externalId where it has one |
fileName | Yes | The name to store the file under, up to 255 characters |
contentType | No | The MIME type. Recommended with contentBase64, which otherwise stores application/octet-stream |
contentBase64 | One of | The bytes, base64-encoded. At most 10 MB once decoded |
url | One of | A public https URL we download the file from. At most 10 MB |
Base64:
curl -s -X POST https://agentlyleads.com/api/v1/files \
-H "Authorization: Bearer $AL_KEY" \
-H "Content-Type: application/json" \
-d '{
"entity": "deal",
"recordId": "cmq2deal0alk34567890",
"fileName": "proposal.pdf",
"contentType": "application/pdf",
"contentBase64": "'"$(base64 < proposal.pdf | tr -d '\n')"'"
}'Base64 makes the request about a third bigger than the file, so for anything that is already reachable on the web, prefer url.
URL:
curl -s -X POST https://agentlyleads.com/api/v1/files \
-H "Authorization: Bearer $AL_KEY" \
-H "Content-Type: application/json" \
-d '{
"entity": "company",
"recordId": "hs_company_456",
"fileName": "w9.pdf",
"url": "https://files.example.com/vendors/acme/w9.pdf"
}'When you leave out contentType, we use the type the remote server reports. The fetch has some limits you should know about:
- The URL must be
httpsand must not carry a username or password. - Redirects are refused. Send the final URL.
- Private, internal and cloud-metadata addresses are refused, both as written and after DNS lookup.
- The download times out after 30 seconds.
A URL that fails any of these returns 400 with the reason.
Two-step upload (up to 50 MB)
For files over 10 MB, the bytes go straight to storage instead of through our API. It takes three requests.
1. Ask for an upload URL. Send the file's name, type and size:
curl -s -X POST https://agentlyleads.com/api/v1/files/upload-url \
-H "Authorization: Bearer $AL_KEY" \
-H "Content-Type: application/json" \
-d '{
"entity": "deal",
"recordId": "cmq2deal0alk34567890",
"fileName": "catalog-2026.pdf",
"contentType": "application/pdf",
"size": 31457280
}'{
"uploadUrl": "https://agentlyleads-files.s3.amazonaws.com/cmq1wksp.../catalog-2026.pdf?X-Amz-Signature=...",
"s3Key": "cmq1wksp0alk34567890/deal/cmq2deal0alk34567890/1727445720000-3f9a1c2b-catalog-2026.pdf",
"recordId": "cmq2deal0alk34567890",
"expiresInSeconds": 300
}The file is checked against the record's rules at this step, so a campaign that has already sent, a full offering, or an oversized photo is refused before you upload anything.
2. PUT the bytes to uploadUrl. Do it within 5 minutes, with the same Content-Type you sent in step 1:
curl -s -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @./catalog-2026.pdfNo API key goes on this request. The signature in the URL is the permission.
3. Attach it. Send the s3Key back to POST /api/v1/files:
curl -s -X POST https://agentlyleads.com/api/v1/files \
-H "Authorization: Bearer $AL_KEY" \
-H "Content-Type: application/json" \
-d '{
"entity": "deal",
"recordId": "cmq2deal0alk34567890",
"fileName": "catalog-2026.pdf",
"s3Key": "cmq1wksp0alk34567890/deal/cmq2deal0alk34567890/1727445720000-3f9a1c2b-catalog-2026.pdf"
}'The size and type are read back from storage at this point, not taken from what you declared in step 1. If the file that landed breaks the record's rules, it is deleted and the request fails with the reason.
Nothing is attached until step 3. If you stop after step 2, the file does not appear on the record. The s3Key only works with the same entity and recordId it was issued for.
Errors you are likely to see
| Status | When |
|---|---|
400 | More or fewer than one of contentBase64, url and s3Key; an empty file; a URL we could not fetch; a file type the record refuses; a full offering; a campaign that has already sent |
404 | No record with that ID or externalId, or nothing has been PUT to the s3Key yet |
413 | The file is over the limit for the method or the record type |
503 | File storage isn't configured on the deployment |
The full list is on Errors.