Build Your Crystallize Service API Like a Pro
A cleanly built Service API isn’t just “glue code” — it’s the core scaffolding that allows your system’s architecture to evolve smoothly.

The article is a practical guide on how to build a Service API (also described as an integration or middleware/service layer) using Crystallize (a headless commerce and PIM-type tool) with modern JavaScript/TypeScript tooling… and, if I’m being honest, it is the one that is long overdue.
Let me get straight to the point. I’ll be focusing on webhook management at two levels:
- A portable Service API with Hono JS + Bun, deployable anywhere.
- An ultra-scalable Service API with Hono JS + Cloudflare Workers + Queues.
Context
First, what exactly is a Service API? In short, it's the service layer (or integration layer) of your system; this is the middleware where your application's business logic lives.
In a headless setup, it appears as follows: the Service API integrates your various components and external services.

Typical responsibilities os a Service API are:
- Communicating with PIM (server-to-server, secured).
- Pushing orders, customers, and stock updates.
- Sending emails, notifications, and handling form submissions.
- Receiving events from Crystallize, payment providers, and more.
Today, many projects skip the external Service API and instead rely on frameworks like Next.js, Remix, Astro, etc. These are “Backend-for-Frontend” frameworks. They bundle frontend and service logic together. While it's not ideally decoupled, it ships things faster.
However, when you require scalability and flexibility, the next step is to separate your Service API into its own application.
In this article, we'll focus on one of its most critical aspects: handling webhooks.
Level 1: Typescript, Hono JS and Bun Compiled Binary Hosted Anywhere
There are many frameworks available for building APIs. I like Hono JS as it's built for performance and can run anywhere. Additionally, I'd like to give ElysiaJS a try, but you can use any framework you prefer, including Koa, Express, or even NestJS.
You will need libraries and the Crystallize JS API Client, combined with the Crystallize Schema, because it will simplify many things. (Version 5, which has just been released, is great.)
We are not going to talk about the specifics of “how to develop”, but personally, I like to use Missive.js for CQRS and Awilix for dependency injection. I would use Zod 4 for types and runtime validation..
But really: you do you. This article focuses on what matters in a Service API, not on specific paradigms and tools.
Handling Webhooks
A webhook is just an API endpoint called by another server with data your Service API must ingest.
“Never trust user input.”
Just as with form submissions, you must validate and authenticate webhook payloads. Most platforms (Stripe, Slack, Crystallize) provide signatures to verify authenticity.
For Crystallize, you can verify signatures here.
import { ClientInterface, createSignatureVerifier, CrystallizeSignature } from '@crystallize/js-api-client';
import { jwtVerify } from 'jose';
export const createCrystallizeWebhooksApp = () => {
const app = new Hono<AppContext>();
const crystallizeSignatureChecker: MiddlewareHandler<AppContext> = async (c, next) => {
const client = {} as ClientInterface; // you would get this from your app context or services
const clone = c.req.raw.clone();
const ab = await clone.arrayBuffer();
const rawBody = new Uint8Array(ab);
const verifier = createSignatureVerifier({
secret: 'signatureSecret from your tenant',
sha256: async (data: string) => {
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(data);
return hasher.digest('hex').toString();
},
jwtVerify: async (token: string, secret: string) => {
const result = await jwtVerify(token, new TextEncoder().encode(secret));
return result.payload as CrystallizeSignature;
},
});
await verifier(c.req.header('x-crystallize-signature'), {
url: c.req.url,
method: c.req.method,
body: rawBody
})
await next();
};
app.use('*', crystallizeSignatureChecker);
}
This middleware verifies each incoming request before it hits your routes. From there, you can safely implement business logic like creating an order when a subscription contract renews.
app.post('/on-subscription-contract-renewed', async (c) => {
// there are many ways to do validation here, we keep it simple
const contract = SubscriptionContractSchema.parse((await c.req.json()))
// you would get this from your app context or services
const client = {} as ClientInterface;
const manager = createOrderManager(client);
// you compute payable usage based on your logic
const payableUsage = computePayableUsage(contract);
// you create the Order Intent using the contract and payable usage
const intent = {} as RegisterOrderInput
const confirmation = await manager.register(intent);
return c.json({ success: true, orderId: confirmation.id });
})
That's it! But how do we know it is working?
Testing Webhook
Webhooks are fired by the external systems. How do we simulate them? Most people would use services like webhook.site or ngrok.
Webhook.site
One approach is to configure a webhook in Crystallize, trigger an event (or wait until one happens), then capture the payload with Webhook.site. You can save that payload and reuse it later in your tests or tools like Postman.
This works fine for initial testing, but it quickly becomes cumbersome. Each time you want to adjust the data, you must go back, retrigger the event, capture a new payload, and repeat the entire flow. It's slow, brittle, and not ideal for iteration.
Ngrok
With Ngrok (or a similar tunneling tool), you can expose your local machine and let Crystallize send webhook payloads straight to it. This is handy for quick debugging, but it comes with trade-offs: it's harder to automate, requires extra setup each time, is harder to integrate with CI, and sharing the setup with teammates is clunky.
The Way (or at least my way)
I like to keep things simple and work directly on my local machine. So, what's a Crystallize webhook, really?
- Concern: subscription contract
- Event: renewed
- Payload: the data you need to handle
For us as developers, the only thing that matters is: how do we process that payload when it arrives? But to do that well, we need to ask: how is this payload actually constructed by Crystallize in the first place?

The payload of a Crystallize webhook is nothing more than the result of a GraphQL query.
Which means: we don't need to sit around waiting for Crystallize to fire real events, we can automate and simulate webhooks ourselves by querying the same payload.
The first step is to create a .graphql file in your source code, for example:
/webhooks/on-subscription-contract-renewed.graphql
query GET_SUBSCRIPTION($id: String!) {
subscriptionContract(id: $id) {
... on BasicError {
message
}
... on SubscriptionContractAggregate {
id
subscriptionPlan {
identifier
periodId
}
item {
name
sku
}
recurring {
...phase
}
status {
renewAt
activeUntil
price
currency
state
}
meta {
key
value
}
customerIdentifier
customer {
identifier
email
firstName
lastName
taxNumber
companyName
phone
meta {
key
value
}
externalReferences {
key
value
}
addresses {
type
lastName
firstName
email
middleName
street
street2
city
country
state
postalCode
phone
streetNumber
}
}
}
}
}
fragment phase on SubscriptionContractPhase {
period
unit
price
currency
meteredVariables {
id
identifier
tierType
tiers {
currency
price
threshold
}
}
}
This is the exact query defined in my Crystallize webhook.
To simulate it locally, I use the magical REST Client for Visual Studio Code extension. (Yes, the name is misleading, but it works beautifully with GraphQL. After all, GraphQL is just another HTTP endpoint.)
With it, I can create a tiny .http file that performs two calls in sequence:
- Fetch the webhook payload from Crystallize.
- Send that payload directly to my local route.
Here's what it looks like:
### Subscription Contract Renewed
# @name SubscriptionContractRenewedPayload
POST https://api{{$dotenv CRYSTALLIZE_ORIGIN }}/@{{tenantIdentifier}} HTTP/1.1
Cookie: connect.sid={{ $dotenv COOKIE_SID }}
Content-Type: application/json
X-REQUEST-TYPE: GraphQL
< ../../webhooks/onAllSubscriptionEvent.graphql
{
"id": "{{ contractId }}"
}
###
POST /api/crystallize/subscription-contract/after-renewal HTTP/1.1
Host: {{ host }}
Content-Type: application/json
{
"subscriptionContract": {{ SubscriptionContractRenewedPayload.response.body.$.data.subscriptionContract }}
}
That's it!
When I click “Send Request”, the extension first fetches the payload using the GraphQL file, then automatically pipes the result into the next request.

With this setup, I can fire the webhook manually whenever I want using variables, environment configs, and all the flexibility I need.
But what about unit or integration tests? For that, I like to create a simulator:
export const createWebhookSimulator = (apiClient: ClientInterface) => {
const simulate = async <T>(queryFilePath: string, variables: Record<string, unknown>) => {
const query = await Bun.file(queryFilePath).text();
return await apiClient.nextPimApi<T>(query, variables);
};
return {
onSubscriptionRenewed: async <T>(id: SubscriptionContract['id']) => await simulate<T>('webhooks/on-subscription-contract-renewed.graphql', { id }),
onSubscriptionCanceled: async <T>(id: SubscriptionContract['id']) => await simulate<T>('webhooks/on-subscription-contract-canceled.graphql', { id }),
onSubscriptionCreated: async <T>(id: SubscriptionContract['id']) => await simulate<T>('webhooks/on-subscription-contract-created.graphql', { id }),
onOrderCreated: async <T>(id: Order['id']) => await simulate<T>('webhooks/on-order-created.graphql', { id }),
}
};
I can then plug this simulator straight into my tests. For example:
it('should handle the Webhook for a newly Subscription renew', async () => {
const webhookPayload = await webhookSimulator.onSubscriptionRenewed<{
subscriptionContract: SubscriptionContract;
}>(collector.subscriptionContractId);
const { orderId } = await postFetch<{ orderId: string }>(
`/on-subscription-contract-renewed`,
webhookPayload,
);
expect(orderId).not.toBeNull();
});
And just like that, you've fully automated the testing of your webhook.
Keeping Queries in Sync
One caveat: if you change a webhook query, you must update it both in code and in Crystallize. Unless you automate it!
By updating the webhook configuration during build/deploy (via mutation), your queries stay in sync with Crystallize.
mutation UPDATE_WEBHOOK($id: ID!, $input: UpdateWebhookInput!) {
updateWebhook(id: $id, input: $input) {
... on Webhook {
id
}
... on BasicError {
errorName
message
}
}
}
The mutation input ends up looking like this:
{
"id": "xxxxxxxxxxxxxxxxx",
"input": {
"name": "On Subscription Contract Renewed",
"preserveDefaultPayload": true,
"headers": [],
"graphqlQuery": "THE CONTENT OF THE GRAPHQL QUERY FILE webhooks/on-subscription-conrtract-renewed.graphql",
"graphqlQueryTarget": "next",
"url": "https://my-service-api.com/on-subscription-contract-renewed",
"method": "POST",
"concern": "subscription-contract",
"event": "renew"
}
}
This way, you can automate the update process however you like, and your code and webhook configuration will always stay perfectly in sync.
Shipping with Bun
With Bun, you can do something magical: compile your Service API into a single binary that runs anywhere.
The simplest way to illustrate this is with a Dockerfile:
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build --compile --sourcemap --format=esm ./src/index.ts --outfile /app/app
# Minimal runtime image with CA certs for HTTPS
FROM debian:bookworm-slim AS runtime
ENV NODE_ENV=production \
PORT=80
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/app /usr/local/bin/app
EXPOSE 80
USER 10001
CMD ["/usr/local/bin/app"]
The first stage builds the binary, and the second stage packages it into a lightweight runtime image. From there, it's ready to ship.
That's Level 1: portable, testable, production-ready.
Level 2: Make it Async!
If you've followed along, you may have noticed that so far we've been handling webhooks synchronously: Crystallize sends a payload, and we process it immediately.
That approach is perfectly fine for many projects. In fact, Level 1 is already production-ready, secure, portable, and a great starting point.
But if your project begins to grow or if you anticipate a high volume of events, you'll eventually want to take it further. Synchronous handling, while effective for small to medium-scale operations, proves unreliable when managing millions of events.
That's when you move to Level 2: async processing with queues.
The pattern is straightforward:
- Verify the signature.
- Acknowledge receipt of the payload.
- Push it into a queue for asynchronous processing.
There are numerous ways to implement this using platforms like Inngest, Trigger.dev, or similar tools.
But if you want something lightweight and natively scalable, you can do it surprisingly easily with Cloudflare Workers.
TOOLKIT: Workers run on V8. You can still use Hono JS, but you have to drop Bun specifics.
Cloudflare Worker Configuration
name = "service-api"
main = "src/index.ts"
queues.producers = [
{ binding = "MY_SERVICE_API_QUEUE", queue = "my-service-queue" },
]
queues.consumers = [
{ queue = "my-service-queue" },
]
Wrangler config binds producers and consumers to your queue.
Here, the my-service-queue queue must exist. This configuration tells Cloudflare that:
- The worker can produce messages to the queue.
- The worker can also consume messages from that queue.
You could split this into two separate workers if you wanted, but there's no real need; it's not the same process anyway.
With that in place, the application code looks like this:
const app = new Hono<AppContext>();
app.use('*', crystallizeSignatureChecker);
app.post('/on-subscription-contract-renewed', async (c) => {
const runtimeEnv = env(c)
const queue = runtimeEnv.MY_SERVICE_API_QUEUE;
const contract = SubscriptionContractSchema.parse((await c.req.json()))
queue.send({ contract, event: 'contract/renewed' })
return c.json({ success: true });
})
app['queue'] = createQueueConsumers()
export default app
The only real difference from the previous version is the addition of createQueueConsumers, which actually handles the messages.
At this point, the Service API itself doesn't process anything. It simply pushes payloads into the queue.
createQueueConsumers might look like this:
export const createQueueConsumers =
() =>
async (
batch: MessageBatch,
env: QueueConsumerEnv,
ctx?: ExecutionContext,
): Promise<void> => {
// you would get this from your app context or services
const client = {} as ClientInterface;
const manager = createOrderManager(client);
for (const message of batch.messages) {
const { contract, event } = message;
// you compute payable usage based on your logic
const payableUsage = computePayableUsage(contract);
// you create the Order Intent using the contract and payable usage
const intent = {} as RegisterOrderInput
await manager.register(intent);
}
}
And that's it, you now have almost unlimited horizontal scaling. 🚀
From here, you can dive deeper into queues, exploring error handling, retries, and dead-letter queues for even more resilience.
A well-designed Service API is more than glue; it's the foundation that lets your architecture evolve without friction.
The key is to start where you are: keep it simple at first, then introduce queues and async workflows as your traffic and complexity grow.
You don’t need to build everything perfectly from day one: begin with a lean, simple implementation.
With Crystallize, these patterns help you move faster while staying ready for scale. You'll find more examples and documentation in the Crystallize developer guides.
Start simple, test thoroughly, and scale with queues when needed, and you'll have a Service API that's both fast to ship and ready for growth. That's what it means to build your Crystallize Service API like a pro.