Uploading and downloading attachments
This recipe assumes you've already familiarized yourself with the core API getting started , authentication, and error handling pages.
Attachments can be added to chat messages, email messages and custom timeline entries. The process to do it is always the same in all cases. The only step that is different is the last one (sending a chat/email vs. creating a custom timeline entry). Here we will use a custom timeline entry:
- Create an attachment upload URL
- Upload the attachment using that URL
- Create the custom timeline entry with the ID of the attachment in it
- Download the attachment by requesting a download URL
We will be using the
mutations createAttachmentUploadUrl
, upsertCustomTimelineEntry
and createAttachmentDownloadUrl
, which require
the following permissions:
createAttachmentUploadUrl
:attachment:create
upsertCustomTimelineEntry
:timeline:create
,timeline:edit
createAttachmentDownloadUrl
:attachment:download
In this guide we will use a picture of Bruce, one of Plain's dogs:

Limitations
- The maximum file size for a single attachment is 6MB (6 * 10^6 bytes).
- A maximum of 25 attachments can be added to a custom timeline entry, a chat message or an email.
- The following file extensions are not allowed as attachments:
bat, bin, chm, com, cpl, crt, exe, hlp, hta, inf, ins, isp, jse, lnk, mdb, msc, msi, msp, mst, pcd, pif, reg, scr, sct, shs, vba, vbe, vbs, wsf, wsh, wsl
- Attachments uploaded, but never referenced by a custom timeline entry, email, or chat message, will be deleted after 24 hours.
- Upload URLs are only valid for 2 hours after which a new URL needs to be created.
- Download URLs are only valid for 3 minutes after which a new URL needs to be created.
Creating an attachment upload URL
Mutation
The GraphQL mutation to create an attachment upload URL is the following:
mutation createAttachmentUploadUrl($input: CreateAttachmentUploadUrlInput!) {
createAttachmentUploadUrl(input: $input) {
attachmentUploadUrl {
attachment {
id
}
uploadFormUrl
uploadFormData {
key
value
}
}
error {
message
type
code
fields {
field
message
type
}
}
}
}
The Attachment Object has more fields you can select, but in this recipe we're only selecting the ID for simplicity.
Variables
Remember to replace c_XXXXXXXXXXXXXXXXXXXXXXXXXX
with an existing customer's ID.
fileName
is the name under which the attachment will appear in the timeline, you can use whichever you wantfileSizeBytes
is the exact size of the attachment in bytes (specifically,32318
bytes is the size of Bruce's picture above)
{
"input": {
"customerId": "c_XXXXXXXXXXXXXXXXXXXXXXXXXX",
"fileName": "bruce.jpeg",
"fileSizeBytes": 32318,
"attachmentType": "CUSTOM_TIMELINE_ENTRY"
}
}
Response
You will get a response similar to the one below. Copy the full response because we will use it in our next step.
{
"data": {
"createAttachmentUploadUrl": {
"attachmentUploadUrl": {
"attachment": {
"id": "att_XXXXXXXXXXXXXXXXXXXXXXXXXX"
},
"uploadFormUrl": "<STRING>",
"uploadFormData": [
{
"key": "acl",
"value": "private"
},
{
"key": "x-amz-server-side-encryption",
"value": "AES256"
},
{
"key": "Content-Type",
"value": "image/jpeg"
},
{
"key": "bucket",
"value": "<STRING>"
},
{
"key": "X-Amz-Algorithm",
"value": "AWS4-HMAC-SHA256"
},
{
"key": "X-Amz-Credential",
"value": "<STRING>"
},
{
"key": "X-Amz-Date",
"value": "<STRING>"
},
{
"key": "X-Amz-Security-Token",
"value": "<STRING>"
},
{
"key": "key",
"value": "<STRING>"
},
{
"key": "Policy",
"value": "<STRING>"
},
{
"key": "X-Amz-Signature",
"value": "<STRING>"
}
]
},
"error": null
}
}
}
Uploading the attachment
Plain's attachments APIs are built in a way which makes them easy to work with on a browser environment. That's why when
you request an upload URL, you get these two fields: uploadFormUrl
and uploadFormData
.
On your website you'd then need to:
- create a form that allows the user to select a file to upload
- call the
createAttachmentUploadUrl
mutation to create an upload URL and form fields - build the form data by using all the items in
uploadFormData
and the contents of thefile
input (the file input needs to be at the end) - submit the form to
uploadFormUrl
Uploading attachments from a server is also possible. You need to build a form (multipart/form-data
) with
the data contained in uploadFormData
and submit it to uploadFormUrl
.
You can upload the attachment straight from the browser using the utility below, by pasting the mutation response from the previous step.
Alternatively, there are code snippets that you can use to upload the attachment with different programming languages, environments or tools.
- Upload from browser
- NodeJS
- Browser
- cURL
Paste the mutation response below and click on 'Upload attachment' to upload the attachment for you.
const axios = require('axios');
const FormData = require('form-data');
/**
* Upload an attachment.
*
* @param {Buffer} fileBuffer Buffer with the contents of the file to upload
* @param {string} uploadFormUrl The url to post the form to (from `createAttachmentUploadUrl.attachmentUploadUrl.uploadFormUrl`)
* @param {{ key: string; value: string }[]} uploadFormData Data to be added to the form along with the file contents (from `createAttachmentUploadUrl.attachmentUploadUrl.uploadFormData`)
*/
function uploadAttachment(fileBuffer, uploadFormUrl, uploadFormData) {
const form = new FormData();
uploadFormData.forEach(({ key, value }) => {
form.append(key, value);
});
form.append('file', fileBuffer, { filename: 'file' });
console.log(`Uploading attachment to ${uploadFormUrl}`);
axios
.post(uploadFormUrl, form, {
headers: {
...form.getHeaders(),
'Content-Length': form.getLengthSync(),
},
})
.then((res) => {
console.log(`File successfully uploaded! (code=${res.status})`);
})
.catch((err) => {
console.log(
`There was an error uploading the file: %s`,
err.response ? err.response.data : err
);
});
}
/**
* Upload an attachment.
*
* @param {Blob} fileBlob blob with the contents of the file to upload
* @param {string} uploadFormUrl The url to post the form to (from `createAttachmentUploadUrl.attachmentUploadUrl.uploadFormUrl`)
* @param {{ key: string; value: string }[]} uploadFormData Data to be added to the form along with the file contents (from `createAttachmentUploadUrl.attachmentUploadUrl.uploadFormData`)
*/
function uploadAttachment(fileBlob, uploadFormUrl, uploadFormData) {
const form = new FormData();
uploadFormData.forEach(({key, value}) => {
form.append(key, value);
});
const file = new File([fileBlob], 'file');
form.append('file', file);
console.log(`Uploading attachment to ${uploadFormUrl}`);
fetch(uploadFormUrl, {
method: "POST",
body: form,
}).then((res) => {
if (!res.ok) {
throw new Error(response.statusText);
}
console.log(`File successfully uploaded! (code=${res.status})`);
}).catch((err) => {
console.log(
`There was an error uploading the file: %s`,
err.message ? err.message : err
);
})
}
curl <UPLOAD_FORM_URL> -F <UPLOAD_FORM_DATA> -F "file=@<PATH_TO_ATTACHMENT_FILE>"
Creating a custom timeline entry
An attachment that is just uploaded and never used will eventually be deleted. Attachments need to be referenced by either a custom timeline entry, email, or chat message to be retained.
In this step we will use the attachment just uploaded and attach it to a small custom timeline entry.
Mutation
mutation upsertCustomTimelineEntry($input: UpsertCustomTimelineEntryInput!) {
upsertCustomTimelineEntry(input: $input) {
result
timelineEntry {
id
customerId
entry {
... on CustomEntry {
title
components {
... on ComponentText {
__typename
text
textSize
textColor
}
}
}
}
}
error {
message
type
code
fields {
field
message
type
}
}
}
}
The TimelineEntry Object and Custom Entry Object have more fields you can select, but in this recipe we're only selecting a few important ones.
Variables
Remember to replace c_XXXXXXXXXXXXXXXXXXXXXXXXXX
with the customer ID you used while creating the attachment upload url.
And replace att_YYYYYYYYYYYYYYYYYYYYYYYYYY
with the attachment ID you received after uploading the attachment
{
"input": {
"customerId": "c_XXXXXXXXXXXXXXXXXXXXXXXXXX",
"title": "Image submitted",
"attachmentIds": [
"att_YYYYYYYYYYYYYYYYYYYYYYYYYY"
],
"components": [
{
"componentText": {
"text": "A new image has been submitted"
}
}
]
}
}
Response
{
"data": {
"upsertCustomTimelineEntry": {
"result": "CREATED",
"timelineEntry": {
"id": "t_YYYYYYYYYYYYYYYYYYYYYYYYYY",
"customerId": "c_XXXXXXXXXXXXXXXXXXXXXXXXXX",
"entry": {
"title": "Image submitted",
"components": [
{
"__typename": "ComponentText",
"text": "A new image has been submitted",
"textSize": null,
"textColor": null
}
]
}
},
"error": null
}
}
}
If you open the customer's timeline, you should now see an entry like this one:

Downloading an attachment
You can only download an attachment after you have attached it to a custom timeline entry, an email or a chat message.
Mutation
mutation createAttachmentDownloadUrl($input: CreateAttachmentDownloadUrlInput!) {
createAttachmentDownloadUrl(input: $input) {
attachmentDownloadUrl {
downloadUrl
expiresAt {
iso8601
}
}
error {
message
type
code
fields {
field
message
type
}
}
}
}
Variables
Remember to replace att_XXXXXXXXXXXXXXXXXXXXXXXXXX
with the attachment ID you received after uploading the attachment
{
"input": {
"attachmentId": "att_XXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
Response
The response will be similar the following. downloadUrl
is the URL you can use to download the file with a GET request.
{
"data": {
"createAttachmentDownloadUrl": {
"attachmentDownloadUrl": {
"downloadUrl": "https://example.com/",
"expiresAt": {
"iso8601": "2022-11-01T11:21:58.055Z"
}
},
"error": null
}
}
}
Download URLs are valid for 3 minutes after which they expire and a new one needs to be generated.