This article covers the process of building the public API for Documenso. It starts by explaining why the API was needed for a digital document signing company in the first place. Then, it'll dive into the steps we took to build it. Lastly, it'll present the requirements we had to meet and the constraints we had to work within.
Why the public API
We decided to build the public API to open a new way of interacting with Documenso. While the web app does the job well, there are use cases where it's not enough. In those cases, the users might want to interact with the platform programmatically. Usually, that's for integrating Documenso with other applications.
With the new public API that's now possible. You can integrate Documenso's functionalities within other applications to automate tasks, create custom solutions, and build custom workflows, to name just a few.
The API provides 12 endpoints at the time of writing this article:
- (GET)
/api/v1/documents
- retrieve all the documents - (POST)
/api/v1/documents
- upload a new document and getting a presigned URL - (GET)
/api/v1/documents/{id}
- fetch a specific document - (DELETE)
/api/v1/documents/{id}
- delete a specific document - (POST)
/api/v1/templates/{templateId}/create-document
- create a new document from an existing template - (POST)
/api/v1/documents/{id}/send
- send a document for signing - (POST)
/api/v1/documents/{id}/recipients
- create a document recipient - (PATCH)
/api/v1/documents/{id}/recipients/{recipientId}
- update the details of a document recipient - (DELETE)
/api/v1/documents/{id}/recipients/{recipientId}
- delete a specific recipient from a document - (POST)
/api/v1/documents/{id}/fields
- create a field for a document - (PATCH)
/api/v1/documents/{id}/fields
- update the details of a document field - (DELETE)
/api/v1/documents/{id}/fields
- delete a field from a document
Check out the API documentation.
Moreover, it also enables us to enhance the platform by bringing other integrations to Documenso, such as Zapier.
In conclusion, the new public API extends Documenso's capabilities, provides more flexibility for users, and opens up a broader world of possibilities.
Picking the right approach & tech
Once we decided to build the API, we had to choose the approach and technologies to use. There were 2 options:
- Build an additional application
- Launch the API in the existing codebase
1. Build an additional application
That would mean creating a new codebase and building the API from scratch. Having a separate app for the API would result in benefits such as:
- lower latency responses
- supporting larger field uploads
- separation between the apps (Documenso and the API)
- customizability and flexibility
- easier testing and debugging
This approach has significant benefits. However, one major drawback is that it requires additional resources.
We'd have to spend a lot of time just on the core stuff, such as building and configuring the basic server. After that, we'd spend time implementing the endpoints and authorization, among other things. When the building is done, there will be another application to deploy and manage. All of this would stretch our already limited resources.
So, we asked ourselves if there is another way of doing it without sacrificing the API quality and the developer experience.
2. Launch the API in the existing codebase
The other option was to launch the API in the existing codebase. Rather than writing everything from scratch, we could use most of our existing code.
Since we're using tRPC for our internal API (backend), we looked for solutions that work well with tRPC. We narrowed down the choices to:
Both technologies allow you to build public APIs. The trpc-openapi
technology allows you to easily turn tRPC procedures into REST endpoints. It's more like a plugin for tRPC.
On the other hand, ts-rest
is more of a standalone solution. ts-rest
enables you to create a contract for the API, which can be used both on the client and server. You can consume and implement the contract in your application, thus providing end-to-end type safety and RPC-like client.
You can see a comparison between trpc-openapi and ts-rest here.
So, the main difference between the 2 is that trpc-openapi
is like a plugin that extends tRPC's capabilities, whereas ts-rest
provides the tools for building a standalone API.
Our choice
After analyzing and comparing the 2 options, we decided to go with ts-rest
because of its benefits. Here's a paragraph from the ts-rest
documentation that hits the nail on the head:
tRPC has many plugins to solve this issue by mapping the API implementation to a REST-like API, however, these approaches are often a bit clunky and reduce the safety of the system overall, ts-rest does this heavy lifting in the client and server implementations rather than requiring a second layer of abstraction and API endpoint(s) to be defined.
API Requirements
We defined the following requirements for the API:
- The API should use path-based versioning (e.g.
/v1
) - The system should use bearer tokens for API authentication
- The API token should be a random string of 32 to 40 characters
- The system should hash the token and store the hashed value
- The system should only display the API token when it's created
- The API should have self-generated documentation like Swagger
- Users should be able to create an API key
- Users should be able to choose a token name
- Users should be able to choose an expiration date for the token
- User should be able to choose between 7 days, 1 month, 3 months, 6 months, 12 months, never
- System should display all the user's tokens in the settings page
- System should display the token name, creation date, expiration date and a delete button
- Users should be able to delete an API key
- Users should be able to retrieve all the documents from their account
- Users should be able to upload a new document
- Users should receive an S3 pre-signed URL after a successful upload
- Users should be able to retrieve a specific document from their account by its id
- Users should be able to delete a specific document from their account by its id
- Users should be able to create a new document from an existing document template
- Users should be able to send a document for signing to 1 or more recipients
- Users should be able to create a recipient for a document
- Users should be able to update the details of a recipient
- Users should be able to delete a recipient from a document
- Users should be able to create a field (e.g. signature, email, name, date) for a document
- Users should be able to update a field for a document
- Users should be able to delete a field from a document
Constraints
We also faced the following constraints while developing the API:
1. Resources
Limited resources were one of the main constraints. We're a new startup with a relatively small team. Building and maintaining an additional application would strain our limited resources.
2. Technology stack
Another constraint was the technology stack. Our tech stack includes TypeScript, Prisma, and tRPC, among others. We also use Vercel for hosting.
As a result, we wanted to use technologies we are comfortable with. This allowed us to leverage our existing knowledge and ensured consistency across our applications.
Using familiar technologies also meant we could develop the API faster, as we didn't have to spend time learning new technologies. We could also leverage existing code and tools used in our main application.
It's worth mentioning that this is not a permanent decision. We're open to moving the API to another codebase/tech stack when it makes sense (e.g. API is heavily used and needs better performance).
3. File uploads
Due to our current architecture, we support file uploads with a maximum size of 50 MB. To circumvent this, we created an additional step for uploading documents.
Users make a POST request to the /api/v1/documents
endpoint and the API responds with an S3 pre-signed URL. The users then make a 2nd request to the pre-signed URL with their document.
How we built the API
Our codebase is a monorepo, so we created a new API package in the packages
directory. It contains both the API implementation and its documentation. The main 2 blocks of the implementation consist of the API contract and the code for the API endpoints.
In a few words, the API contract defines the API structure, the format of the requests and responses, how to authenticate API calls, the available endpoints and their associated HTTP verbs. You can explore the API contract on GitHub.
Then, there's the implementation part, which is the actual code for each endpoint defined in the API contract. The implementation is where the API contract is brought to life and made functional.
Let's take the endpoint /api/v1/documents
as an example.
export const ApiContractV1 = c.router(
{
getDocuments: {
method: 'GET',
path: '/api/v1/documents',
query: ZGetDocumentsQuerySchema,
responses: {
200: ZSuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get all documents',
},
...
}
);
The API contract specifies the following things for getDocuments
:
- the allowed HTTP request method is GET, so trying to make a POST request, for example, results in an error
- the path is
/api/v1/documents
- the query parameters the user can pass with the request
- in this case -
page
andperPage
- in this case -
- the allowed responses and their schema
200
returns an object containing an array of all documents and a fieldtotalPages
, which is self-explanatory401
returns an object with a message such as "Unauthorized"404
returns an object with a message such as "Not found"
The implementation of this endpoint needs to match the contract completely; otherwise, ts-rest
will complain, and your API might not work as intended.
The getDocuments
function from the implementation.ts
file runs when the user hits the endpoint.
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
const { data: documents, totalPages } = await findDocuments({
page,
perPage,
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: {
documents,
totalPages,
},
};
}),
...
});
There is a middleware, too, authenticatedMiddleware
, that handles the authentication for API requests. It ensures that the API token exists and the token used has the appropriate privileges for the resource it accesses.
That's how the other endpoints work as well. The code differs, but the principles are the same. You can explore the API implementation and the middleware code on GitHub.
Documentation
For the documentation, we decided to use Swagger UI, which automatically generates the documentation from the OpenAPI specification.
The OpenAPI specification describes an API containing the available endpoints and their HTTP request methods, authentication methods, and so on. Its purpose is to help both machines and humans understand the API without having to look at the code.
The Documenso OpenAPI specification is live here.
Thankfully, ts-rest
makes it seamless to generate the OpenAPI specification.
import { generateOpenApi } from '@ts-rest/open-api';
import { ApiContractV1 } from './contract';
export const OpenAPIV1 = generateOpenApi(
ApiContractV1,
{
info: {
title: 'Documenso API',
version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
},
},
{
setOperationId: true,
},
);
Then, the Swagger UI takes the OpenAPI specification as a prop and generates the documentation. The code below shows the component responsible for generating the documentation.
'use client';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
};
export default OpenApiDocsPage;
Lastly, we create an API endpoint to display the Swagger documentation. The code below dynamically imports the OpenApiDocsPage
component and displays it.
'use client';
import dynamic from 'next/dynamic';
const Docs = dynamic(async () => import('@documenso/api/v1/api-documentation'), {
ssr: false,
});
export default function OpenApiDocsPage() {
return <Docs />;
}
You can access and play around with the documentation at documenso.com/api/v1/openapi. You should see a page like the one shown in the screenshot below.
This article shows how to generate Swagger documentation for a Next.js API.
So, that's how we went about building the first iteration of the public API after taking into consideration all the constraints and the current needs. The GitHub pull request for the API is publicly available on GitHub. You can go through it at your own pace.
Conclusion
The current architecture and approach work well for our current stage and needs. However, as we continue to grow and evolve, our architecture and approach will likely need to adapt. We monitor API usage and performance regularly and collect feedback from users. This enables us to find areas for improvement, understand our users' needs, and make informed decisions about the next steps.