
Calling External APIs from a Contentful App is Slow? We Reduced the Approximately 3 Seconds via App Actions to Approximately 1 Second with Signed Requests
This page has been translated by machine translation. View original
This is Konishi from the Berlin office.
DevelopersIO articles are managed and written in Contentful. A tag recommendation feature has been added as a custom plugin in the article tags field.

Previously we were using Claude Haiku 4.5 (Bedrock + Lambda), but recently switched to the decision model Clef released by Cloudflare. After rebuilding it as a Cloudflare Workers API and comparing it on several hundred sample articles, both accuracy and speed improved (about 3 seconds → about 0.7 seconds), so we deployed it. However, when pressing the button in the CMS screen, there was a noticeable delay. Measuring it showed about 3 seconds. Even though the API alone takes 0.7 seconds.
This article explains why it was taking 3 seconds and how it was resolved with App Signed Requests.
For an overview of the Custom App, see the previous article.
Previous Configuration: App Action + Functions
Initially, external APIs were called via App Action + Contentful Functions, because we wanted to keep the external API credentials on the server side.
Flow:
- The browser (App frontend) calls
sdk.cma.appActionCall.createWithResponse - Contentful creates the App Action call and starts the Function
- The Function calls the external API
- The browser waits until the Function result is ready
Where Time Is Being Spent
Out of approximately 3.2 seconds total, more than 70% (about 2.3 seconds) was on the Contentful side.
These are the results from measuring three locations: the browser, Function, and external API (median of 5 measurements from Berlin using Chrome).
| Total | Function | Processing inside external API | Contentful side (Total − Function) |
|---|---|---|---|
| 3,186ms | 856ms | 742ms | 2,330ms |
Processing inside the external API takes about 0.7 seconds, communication between the Function and external API takes about 0.1 seconds, and the Function itself uses almost no time.
Note that "Contentful side" is the remainder after subtracting the Function time from the total, so it also includes the communication time between the browser and Contentful API. Also, when going through the Function, the external API (Worker) was running in the eastern United States (IAD) (confirmed via logs), suggesting the Function is executing in the US.
Why It Takes So Long
The cause is that createWithResponse polls for results at 2-second intervals by default. The contentful-management code bundled in the App SDK's sdk.cma works as follows:
- Creates the App Action call (
POST /spaces/{space}/environments/{env}/app_installations/{app}/actions/{action}/calls). At this point, only the call ID is returned - Retrieves the result (
GET .../actions/{action}/calls/{callId}) - If the result is not yet ready, waits 2 seconds and retries (default value; 2000ms if
retryIntervalis not passed)
Even if the Function finishes in about 0.9 seconds, the first retrieval finds no result yet, so it waits 2 seconds and retries, resulting in about 3 seconds. If that's still not enough, another 2 seconds. The 5.5-second result that occurred once in 5 tries is likely due to this.
With the default value, the wait time is determined in "2-second increments" rather than by the speed of the external API, so the experience is essentially the same whether the external API takes 0.7 seconds or 0.3 seconds. The speed improvement from Clef was being offset.
It's possible to shorten the interval by passing retryInterval when calling, but that wasn't tried this time. Even with a shorter interval, the round trips for creating the call and retrieving the result, as well as Function startup, would remain.
It seems designed for asynchronous processing where some delay is acceptable, and doesn't seem well-suited for screens where you want immediate results after pressing a button.
Solution: Calling Directly from the Browser with Signed Requests
Using App Signed Requests, you can skip App Action, Functions, and result retries, completing the whole thing in a single browser → external API round trip. There's no need to store API tokens in the browser either.
The mechanism works by having the App frontend ask Contentful to "sign this request," receive the signature headers, and then use them. The external API side verifies the signature using the signing secret for each App definition and only accepts requests from the correct App.
Frontend Side
Get the signature headers (additionalHeaders) with sdk.cma.appSignedRequest.create and attach them directly to the fetch call.
const requestBody = JSON.stringify({ text, entryId: sdk.ids.entry });
const request = {
method: 'POST' as const,
path: '/suggest',
headers: { 'Content-Type': 'application/json' },
body: requestBody,
};
const { additionalHeaders } = await sdk.cma.appSignedRequest.create(
{ appDefinitionId: sdk.ids.app! },
request
);
const res = await fetch(`${apiUrl}/suggest`, {
method: 'POST',
headers: { ...request.headers, ...additionalHeaders },
body: requestBody,
});
The signed body (requestBody) and the sent body must be the same string. Rebuilding the JSON can change the key order and other things, causing the signature to not match.
Signing Secret
One 64-character signing secret is set per App definition. It can be created via the management screen (Security tab) or with CMA's PUT /organizations/{org}/app_definitions/{id}/signing_secret. Retrieving it will show the value masked, so store it in the external API's secrets at the time of creation.
[Image: App definition Security tab (Request verification settings screen)]
What to Do on the External API Side
On the external API side, the following four things were addressed.
- Signature verification: Recreate the signature using HMAC-SHA256 from the method, path, signed headers, and body, then compare. The expiry is 30 seconds from the signature (the default for the official library). The official
verifyRequest(@contentful/node-apps-toolkit) assumes Node, so for Cloudflare Workers, the same procedure was implemented using Web Crypto. Like the official version, constant-time comparison (timing attack countermeasure) and expiry check (replay attack countermeasure) are also included - Put parameters in the body: Query strings were put in the body to avoid escaping issues during signing
- CORS: Since the browser calls directly, preflight requests are sent.
Access-Control-Allow-Headersneeds to include signature-related headers such asx-contentful-signature - Restrict by space: Check the signed
x-contentful-space-idand reject with 401 for any space other than the designated one
Note that what the signature guarantees is only that "this is a request via the App of that space." Editors of the space can call the API with a legitimate signature from developer tools, so this needs to be kept in mind.
Results
It went from about 3.2 seconds to about 1.1 seconds (median, −65%). The difference in feel is quite noticeable.
| App Action + Functions | Direct with Signed Requests | |
|---|---|---|
| Median | 3,186ms | 1,101ms (−65%) |
| Average | 3,606ms | 1,275ms |
| Max | 5,547ms | 1,727ms |
After the change, it's roughly in line with the external API's processing time, and variance is smaller too (max 5.5 seconds → 1.7 seconds). The breakdown (median) is: signature retrieval 312ms, communication between browser and external API 109ms, processing inside external API 700ms.
However, since signature retrieval also involves a round trip to Contentful, it can occasionally be slow (there was one case of 0.88 seconds).
Which to Choose
App Action + Functions isn't bad — I think it's a matter of what each is suited for.
| App Action + Functions | Direct with Signed Requests | |
|---|---|---|
| Speed | With defaults, result retrieval is at 2-second intervals, so delays come in 2-second increments | External API time + signature retrieval (about 0.3 seconds) |
| Secret information | Can be stored on the Function side | No secrets needed in the browser, since the external API verifies the signature |
| Work on external API side | Not needed | CORS configuration and signature verification are required |
| Suited for | Long-running processes, cases where external API cannot be modified | Processes where response speed matters, cases where you own the external API |
If you can modify the external API, signed requests are the more straightforward approach for processes called from the UI.
Note: Don't Store Secrets in Installation Parameters
App installation parameters (sdk.parameters.installation) with regular types can be read from the browser. Storing API tokens or HMAC secrets there means anyone who can edit entries can view them with developer tools.
To store secrets, use Secret type installation parameters in the App definition. The raw values of Secret type parameters can only be read on the backend side such as Functions, and are masked from the App SDK or CMA (personal access tokens). With signed requests, the token itself isn't needed.
Closing
App Signed Requests itself is an existing feature, and is documented in the official documentation for calling your own backend from an App frontend. App Action + Functions tends to be more visible, but for calls from the UI, signed requests turned out to be the more straightforward choice. If something feels slow in a Contentful App, it might be worth questioning the request path.
