Skip to content
epilot-berater.de

Blog

Processing epilot webhooks securely

How to process an epilot webhook securely: verify the signature, check the timestamp, respond fast, handle retries and replays, and make processing idempotent.

By Last updated: 11 min read

Processing an epilot webhook securely comes down to four things: verify the signature, reject stale requests, respond quickly and recognise duplicate deliveries. This article shows what that looks like in a dedicated Java service built with Quarkus, and which parts of epilot’s documentation it relies on.

I am Marcel R. G. Berger, a software developer and architect with more than 20 years of experience. I work with epilot as an external consultant and developer for apps and customisations. This piece is for IT leads and developers at grid operators, municipal utilities and energy suppliers who pass data from epilot on to their own systems.

What is an epilot webhook and how does it arrive?

An epilot webhook is an HTTP POST that epilot sends to a URL you configure whenever a matching event occurs. The overview of epilot webhooks describes the request as carrying a signed payload, with your server verifying it and answering 200 OK.

Two technical details matter before you write any code.

  • epilot sends payloads with Transfer-Encoding: chunked. The body arrives in parts, and a zero-length chunk marks the end. Most current servers handle this without any extra work, Quarkus included.
  • The payload structure documentation describes a metadata object with fields such as organization_id, webhook_id and correlation_id, plus an entity object holding the actual data. A JSONata transformation can change that shape.

So the path from event to action is well defined. How a mapper then converts and forwards the data is covered in building mappers between epilot and other systems with Quarkus. This article is about the step before that: accepting the request safely.

Why should you verify every webhook signature?

You should verify every webhook signature because otherwise your endpoint accepts whatever reaches its URL. The security page for epilot webhooks is blunt about it: always verify signatures before processing payloads, because an attacker could otherwise send forged requests.

For a grid operator this is not a theoretical concern. A webhook may create a case in a line-of-business system, pass on customer data or kick off a process. A forged request that slips through creates real work and, at worst, wrong data.

Every request from epilot carries three headers that make verification possible.

HeaderContent according to epilot’s docsWhat you use it for
webhook-idunique message identifierpart of the signed content, candidate idempotency key
webhook-timestampUnix timestamp in seconds when the request was signedpart of the signed content, protection against replay attacks
webhook-signaturespace-separated signatures v1a,... and v1,...the actual check

According to the documentation, the signed content is webhook-id.webhook-timestamp.body, the three parts joined with dots. The body counts exactly as it arrives on the wire.

Which signatures does epilot send, and which should you check?

epilot sends two signatures, and each answers a different question. The security page describes them as follows.

SignatureSchemeWhat it provesKey
v1aasymmetric, Ed25519the request came from your organisationyour organisation’s public key
v1symmetric, HMAC-SHA256the request is meant for this specific webhookthe whsec_... signing secret from webhook creation

For most cases epilot recommends checking the symmetric v1 signature. If you want to verify both, you fetch your organisation’s public key from a documented endpoint and cache it, since the docs say it rarely changes.

One point deserves attention. The documentation states that the signing secret is returned only once, in the response when the webhook is created. If it is lost, the webhook has to be recreated. Put it into a secret store straight away, not into a config file in the repository.

epilot’s examples use the npm package standardwebhooks, which implements the Standard Webhooks specification. According to the specification, the secret after the whsec_ prefix is base64 encoded, and so is the signature after v1,. For a Java service that means rebuilding the same check with what the JDK already provides.

How do you verify an epilot webhook signature in Quarkus?

You verify the signature in Quarkus with a small class that computes the HMAC-SHA256 over the signed content and compares it with the value from the header. javax.crypto.Mac and java.security.MessageDigest from the JDK are all you need.

@ApplicationScoped
public class SignatureVerifier {

    private static final long TOLERANCE_SECONDS = 300;
    private final byte[] key;

    public SignatureVerifier(@ConfigProperty(name = "epilot.webhook.secret") String secret) {
        if (!secret.startsWith("whsec_")) {
            throw new IllegalStateException("Unexpected secret format");
        }
        this.key = Base64.getDecoder().decode(secret.substring("whsec_".length()));
    }

    public boolean isValid(String id, String timestamp, String signatureHeader, byte[] body) {
        if (id == null || timestamp == null || signatureHeader == null) {
            return false;
        }
        long sentAt;
        try {
            sentAt = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            return false;
        }
        if (Math.abs(Instant.now().getEpochSecond() - sentAt) > TOLERANCE_SECONDS) {
            return false;
        }
        byte[] expected = hmac(id + "." + timestamp + ".", body);
        for (String part : signatureHeader.split(" ")) {
            if (part.startsWith("v1,")) {
                try {
                    byte[] given = Base64.getDecoder().decode(part.substring(3));
                    if (MessageDigest.isEqual(expected, given)) {
                        return true;
                    }
                } catch (IllegalArgumentException e) {
                    return false;
                }
            }
        }
        return false;
    }

    private byte[] hmac(String prefix, byte[] body) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(key, "HmacSHA256"));
            mac.update(prefix.getBytes(StandardCharsets.UTF_8));
            return mac.doFinal(body);
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(e);
        }
    }
}

Three choices in this code are deliberate.

  • The body is passed as byte[], not as a parsed object. The signature covers the unmodified content, and even JSON with different whitespace produces a different signature.
  • The comparison uses MessageDigest.isEqual. The Java documentation for MessageDigest describes it as a comparison whose duration does not depend on where two values differ. The Standard Webhooks specification asks for exactly that kind of comparison.
  • The timestamp is checked against a five-minute tolerance. That matches the full verification example in epilot’s documentation and stops an intercepted request from being replayed later.

The secret comes in through @ConfigProperty, which Quarkus can feed from environment variables or a secret store. The Quarkus configuration reference explains how.

What other layers of protection does epilot offer for the interface?

Beyond the signature, the security page lists three methods for authenticating epilot’s calls to your endpoint. They do not replace signature verification, they add to it.

MethodHow epilot implements it according to the docs
API keyepilot sends the configured key in the X-API-Key header
Basic authepilot sends username and password base64 encoded in the Authorization: Basic header
OAuthepilot obtains a token from your authorisation server and sends it as Authorization: Bearer, refreshing it automatically

Credentials do not have to sit in the webhook configuration in plain text. According to the documentation on environments and secrets in webhooks, the URL, headers and the fields for basic auth, API key and OAuth can reference environment variables (env.*). SecretString values are resolved only when the request is sent and, per the docs, never logged.

For organisations with strict firewall rules there is one more option. The page on static IPs explains that enterprise customers can ask support to have webhooks sent from a fixed set of addresses that can then be allowlisted.

How should the endpoint respond, and how quickly?

The endpoint responds correctly when it verifies first, then stores the message safely, and only then confirms with a 2xx. The real work, converting the data and calling downstream systems, should not happen inside the same request.

The reason is simple. A downstream system that is slow or unavailable must not block the acceptance of the webhook, or a problem in one system turns into a failure in epilot. A Quarkus REST resource for this follows the Quarkus guide to REST services.

@Path("/webhooks/epilot")
public class WebhookResource {

    @Inject
    SignatureVerifier verifier;

    @Inject
    Inbox inbox;

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public Response receive(@HeaderParam("webhook-id") String messageId,
                            @HeaderParam("webhook-timestamp") String timestamp,
                            @HeaderParam("webhook-signature") String signature,
                            byte[] body) {
        if (!verifier.isValid(messageId, timestamp, signature, body)) {
            return Response.status(Response.Status.UNAUTHORIZED).build();
        }
        inbox.storeIfNew(messageId, body);
        return Response.accepted().build();
    }
}

Inbox is a class of the project itself, not an off-the-shelf library. It writes the message to a table that a background process works through. If a known identifier arrives again, nothing new is stored, and the response is still successful.

The table below shows how the endpoint should react in typical situations. This is my recommended design, not a requirement from epilot.

SituationResponseWhat happens internally
Signature or timestamp invalid401nothing stored, incident logged
Message valid and new202 or 200message stored, processed in the background
Message valid, identifier already known200nothing new stored
Own database unavailable503delivery remains visible as failed in epilot

What happens when a delivery fails?

When a delivery fails, you see it in epilot’s webhook monitoring and can replay the event. The monitoring documentation explains that every request is logged as an event with a success or failure status.

According to the docs, you can inspect payload, headers and response code for each delivery. A replay sends the same payload again and creates a new event record. Events are retained for four weeks by default.

That leads to two operating rules.

  1. Your service must tolerate a repeated delivery. Replays are an intended feature, so receiving the same data a second time has to be harmless.
  2. The four weeks are a debugging window, not an archive. Anything you need to prove for longer belongs in your own logging.

The pages of epilot’s documentation I reviewed for this article do not describe automatic retries of failed deliveries. I therefore do not assume them, and I design processing to be correct with or without them.

How does idempotency stop data from being processed twice?

Idempotency makes sure that a message arriving twice only has an effect once. For an epilot webhook, that means a replay must not create a second case in the downstream system.

The webhook-id is the obvious key. epilot’s documentation describes it as a unique message identifier, and the Standard Webhooks specification recommends using it as an idempotency key. I recommend it too, with two additions.

  • The documentation does not say whether a replay from monitoring carries the same webhook-id as the original delivery. Check a business key from the data as well, for example the record identifier together with the operation it triggers.
  • The check belongs in the same transaction as the write. A unique column in the database is more robust than a lookup beforehand, because it still holds when two deliveries arrive at the same moment.

Idempotency helps on the downstream side too. If a line-of-business system accepts its own key for create operations, the mapper should send one. A retry then stays harmless there as well.

How do you handle retries towards other systems and epilot?

You retry calls to downstream systems deliberately, with a delay and an upper limit, and you respect the Retry-After header when calling epilot. Retrying blindly and immediately only slows an overloaded system down further.

For calls to downstream systems, Quarkus offers annotations such as @Retry and @Timeout through SmallRye Fault Tolerance. The Quarkus guide to SmallRye Fault Tolerance shows how to set the number of attempts, the delay and the failures that should trigger a retry. Retry only on transient errors, never on data that was rejected.

When the service writes results back to epilot, the platform’s rate limits apply. The rate limits documentation explains that the API responds with HTTP 429 when a limit is exceeded, and that the Retry-After header says how many seconds remain until the quota resets. The service should wait exactly that long and not retry earlier.

Because processing runs in the background from the stored table, such a wait costs nothing. The webhook has long been acknowledged, and the message stays put until the next attempt succeeds.

When is epilot enough on its own, and when do you need your own service?

epilot is enough on its own when a webhook passes data in one direction to one target and the fields can be translated with lookup tables. epilot is an excellent no-code and low-code platform, and payload transformation with JSONata and environment variables covers those cases well.

A dedicated service makes sense when your target cannot verify signatures itself, when several systems are involved in sequence, or when results flow back into epilot. Where that line usually falls is explained in when low-code on epilot is no longer enough. The full set of routes is covered in connecting ERP, billing and third-party systems to epilot.

That is how I work in my current project with a grid operator in Hesse. Many processes run outside epilot there, and several third-party systems are connected through individually developed interfaces. The mapper tools are built in Java with Quarkus, run in containers, and are scalable and maintainable. There is more on this in the in practice section on the home page.

I complement epilot’s consulting team, I do not replace it. epilot remains your point of contact for the platform and its standard scope. I step in where classic software development is needed. The services I offer to epilot customers are listed on the home page.

How do you get started with secure webhook processing?

You start with a free intro call in which we go through your webhooks, surrounding systems and operational requirements. Afterwards it is clear whether epilot’s own tools are enough or whether a dedicated service makes sense.

  1. Intro call: free and without obligation, 30 minutes on Microsoft Teams.
  2. Individual quote: tailored to your webhooks, systems and requirements.
  3. Delivery: signature verification, storage, processing and tests are built and put into operation with your systems.

The code belongs to you. On request I hand it over to your own repository, or I look after it under a maintenance contract. Why that matters is explained in who owns the integration code. The contracting party is DigitalFreedom Global LLC, your data stays in Europe, and where needed we sign a data processing agreement. The details are in the contracting party section.

Everything is offered on the basis of an individual quote. You will find answers to further questions in the FAQ on the home page, and you can pick a slot directly in the booking calendar.

Questions on this topic

Do I really need to verify the signature of an epilot webhook?
Yes. The security page in epilot's documentation explicitly recommends always verifying signatures before processing payloads. Without that check, anyone who knows your URL could send forged requests to your endpoint.
Which scheme does epilot use for webhook signatures?
According to the documentation, epilot sends two signatures. v1a is asymmetric, using Ed25519, and proves the request came from your organisation. v1 is symmetric, using HMAC-SHA256, and proves it is meant for this specific webhook. Both are computed over the webhook-id, the timestamp and the body.
Does epilot retry failed webhooks?
The documentation describes that every delivery is logged as an event with a status and can be replayed from the monitoring view. The pages I reviewed do not describe automatic retries. Design your processing so that it copes with duplicate deliveries either way.
Is the webhook-id enough to prevent duplicate processing?
It is a good first key, and the Standard Webhooks specification recommends it as an idempotency key. The epilot documentation does not say whether a replay from monitoring carries the same webhook-id, so I recommend adding a business key taken from the data.
Can I process webhooks without a dedicated service?
For simple cases, yes. epilot offers payload transformation with JSONata and environment variables. A dedicated service makes sense once several systems are involved, or data has to be validated, enriched or written back to epilot.
What does a secure webhook setup cost?
That depends on the number of webhooks, the surrounding systems and your operational requirements. After the free intro call you receive an individual quote.

Does this fit your project?

In the free 30-minute intro call on Microsoft Teams we work out where epilot's standard ends for you. Afterwards you receive an individual quote.

Book a call

Published: . Last updated: .