The Shared Persistence Layer: Trading Autonomy for Standardization in Microservices

Multi-Tenant Persistence Strategies

The Real Cost of Unlimited Autonomy

At some point in every growing microservices organization, someone realizes that fifteen different teams have written fifteen slightly different versions of the same ORM configuration, the same transaction wrapper, the same connection error handler. Nobody planned for this. It just happened.

This is not an edge case — it is a predictable consequence of taking the database-per-service pattern to its logical conclusion without governance guardrails. Each service owns its data store entirely, which sounds ideal in theory. In practice, teams end up reimplementing the same logic over and over: connection pooling, transaction boundaries, ORM configuration, security hardening, query error handling.

The operational cost compounds as the system grows. A global security patch requires touching dozens of independent codebases, each with its own conventions. Cross-team collaboration suffers when there is no shared interface to the data layer. As the number of database instances increases, so does the burden of monitoring, backup scheduling, and infrastructure management.

This is not a failure of microservices architecture itself — it is a governance gap. The question becomes: how much persistence logic should truly be per-service, and how much can be safely shared without sacrificing the independence that makes microservices valuable?

A shared library is the most obvious answer, but it falls short. It can standardize code at compile time, but cannot centralize governance at runtime. Each service would still manage its own database instance, credentials, and schema migrations independently. What is needed is a runtime layer, not just a compile-time one. That distinction is what makes the multi-tenant model a useful architectural reference for solving this problem.

Adapting Multi-Tenancy to a Microservices Context

In a multi-tenant system, multiple clients share a single software instance while their data remains logically or physically isolated. The fundamental challenge is identical to the one microservices face: maximize resource sharing while enforcing strict data boundaries.

Three strategies are commonly used in multi-tenant persistence design, and each offers a different point on the spectrum between isolation and efficiency.

Multi-Tenant Persistence Strategies

Database-per-tenant provisions a dedicated database for each client. Data isolation is physical and complete — schema migrations in one tenant have no effect on others, and databases can be distributed across different servers as load demands. The downside is operational: as the tenant count grows, so does the complexity of managing independent instances.

Schema-per-tenant keeps all tenants in a single database server but separates their data into distinct schemas. This reduces infrastructure overhead while maintaining logical isolation. The critical failure mode is resource contention: if one tenant runs expensive queries at high frequency, it can saturate the DBMS connection pool and starve other tenants of access.

Discriminator column takes sharing furthest — all tenants share both the database and the schema, with a dedicated column on each table identifying data ownership. Infrastructure reuse is maximized, but the approach introduces risks around naming conflicts and requires the application layer to enforce isolation consistently on every query.

For a microservices context, the database-per-tenant strategy is the most appropriate fit. Each microservice has its own schema lifecycle — tables are added and modified independently, at different cadences, by different teams. Physical isolation ensures that a destructive migration in one service cannot propagate to another, and that each service’s data can be backed up, restored, or scaled independently. The infrastructure cost is a real trade-off, but it is one the centralizer is specifically designed to absorb.

The Data Persistence Centralizer

The Data Persistence Centralizer is a microservice that acts as a standardized intermediary between other services and their relational databases. It exposes a unified API for data operations while transparently handling connection lifecycle, transaction boundaries, and per-service schema provisioning.

The core principle: services remain the owners of their data, but the mechanics of persistence are delegated to a shared, well-governed component. A service team defines their data model using standard JPA annotations — the same way they would for any Spring/Hibernate application — and the centralizer handles the rest.

Architecture Overview

Registration: Schema Provisioning at Runtime

Rather than requiring manual database setup, the centralizer provisions schemas automatically through a registration flow. A service registers itself by sending a multipart HTTP request containing four pieces of information:

Registration Flow

  • Name — a unique identifier for the service, used to name its dedicated database.
  • Password — used to authenticate subsequent requests and generate a signed access token.
  • JAR file — a Java Archive containing the JPA @Entity class definitions that describe the service’s data model.
  • Package name — the path within the JAR where the entity classes are located.

On receiving this payload, the centralizer dynamically loads the JAR using a custom ClassLoader, introspects the entity definitions, and passes them to Hibernate’s SchemaExport — a tool that reads JPA metadata and generates the corresponding DDL statements (CREATE TABLE, column definitions, constraints) without requiring manual SQL. The centralizer then creates a dedicated database for the service and executes those statements against it. Finally, it returns a JWT-based access token the service will use for all subsequent operations.

If the service has registered before, the centralizer detects it by name and runs a schema diff instead of reprovisioning from scratch. Importantly, only additive changes are applied — new columns and tables — and existing structures are never dropped. This prevents accidental data loss when a service updates its model and re-registers.

CRUD Operations: A Unified, Database-Agnostic API

Once registered, a service executes data operations through a single endpoint authenticated with its access token. Four command types are supported:

Command Execution Flow

  • SELECT — accepts a JPQL query string with optional named parameters for filtering.
  • INSERT — accepts a JSON payload and a class name; the centralizer maps it to the corresponding entity and persists it.
  • UPDATE — same structure as INSERT, requiring the primary key to be present in the payload for record identification.
  • DELETE — accepts the entity identifier and class name.

Multiple commands can be batched into a single API call. The centralizer wraps the entire batch in a single ACID transaction: if any command fails, a full ROLLBACK is issued and no partial changes are committed. Results are returned in the same order as the commands were sent, making the behavior predictable for the calling service.

The API uses JPQL rather than raw SQL, which keeps it database-agnostic at the query level — a service does not need to know whether it is talking to MySQL or PostgreSQL. The trade-off is real: JPQL covers only what the JPA specification defines, and services that require vendor-specific features, window functions, or complex analytical queries will find themselves working around the API rather than through it. For services with straightforward CRUD needs, this boundary is rarely a problem. For services with advanced reporting or full-text search requirements, it can become a ceiling.

Observability and Operational Governance

A central claim of the centralizer is that it simplifies governance — but governance without visibility is incomplete. Because all persistence traffic flows through a single component, the centralizer is uniquely positioned to expose metrics that would otherwise be scattered across dozens of independent services.

Connection pool saturation per tenant, query latency distributions, transaction rollback rates, and failed authentication attempts are all observable at the centralizer level without instrumenting each service individually. This makes anomaly detection significantly simpler: a spike in rollback rate for a specific tenant is immediately visible, rather than buried in that service’s private logs.

This observability advantage is also what makes the single-point-of-failure risk manageable in practice — a well-monitored centralizer can be configured for high availability, with connection pool limits enforced per tenant to prevent one high-traffic service from degrading others.

Trade-off Analysis: When This Makes Sense

Centralizing persistence is an architectural bet, not a universal best practice. It shifts the governance problem from “every team managing their own persistence” to “one component that every team depends on.” That shift carries real consequences.

Where it helps most: teams that are rapidly spinning up new services benefit significantly from not having to bootstrap persistence infrastructure from scratch each time. Standardization also makes cross-cutting concerns — access control, connection limits, transaction handling — straightforward to enforce consistently. For organizations with limited platform engineering resources, a single well-maintained centralizer can raise the quality floor across the board without requiring every team to become a database expert.

Where it introduces risk: the centralizer is a single point of failure. A concrete scenario: if one high-traffic service exhausts the centralizer’s connection pool, every other registered service begins timing out on writes — not because of anything wrong in those services, but because of a dependency they cannot control. This is the opposite of the resilience that microservices architecture promises. Mitigation requires per-tenant connection limits, health checks, and circuit breakers — which adds operational complexity back into the picture.

The additional network hop between a service and the centralizer adds measurable latency: serialization, deserialization, HTTP overhead, and the centralizer’s internal processing. For services with strict sub-millisecond SLAs, this overhead may be prohibitive regardless of other benefits.

The long-term risk worth naming explicitly: abstraction decay. As services mature and their querying needs evolve — reporting queries, pagination, full-text search, aggregations — the centralizer’s generic API may start to feel like a constraint rather than a convenience. Teams will build workarounds, and those workarounds will accumulate into the same kind of inconsistency the centralizer was meant to prevent. This is not a hypothetical: it is the standard lifecycle of any persistence abstraction that outgrows its original design assumptions. Architects adopting this pattern should define upfront which query capabilities are out of scope for the centralizer, and what the intended path is for services that need them.

Conclusion

The Data Persistence Centralizer is a practical answer to a real governance problem: not code redundancy in the abstract, but the concrete operational cost of maintaining dozens of isolated persistence implementations without shared standards.

It works best in greenfield platform environments where teams need to move fast, consistency matters more than maximum autonomy, and performance requirements are within the overhead budget of an additional network layer. It is a poor fit for latency-sensitive services, architectures that treat strict decoupling as non-negotiable, or systems where advanced query patterns are the norm rather than the exception.

Autonomy has a price. The question is never whether to pay it, but whether what you get in return justifies the cost.

Source code: github.com/lucasheartcliff/centralized-data-persistence

SAP ABAP Performance Optimization: Identifying and Fixing Bottlenecks in Real-World Systems

SAP ABAP Performance Optimization: Identifying and Fixing Bottlenecks in Real-World Systems
If you’ve spent any meaningful time in SAP development, you already know the feeling: a report that worked fine in the development system crawls to a halt in production, or a background job that used to finish overnight now runs well into the next business day. ABAP performance optimization isn’t a luxury skill—it’s a core competency for anyone who takes SAP engineering seriously. In this guide, I’ll walk you through a structured, battle-tested approach to diagnosing and resolving performance bottlenecks in ABAP programs, drawing on patterns I’ve encountered across dozens of real-world enterprise deployments.

We’ll go beyond generic advice. You’ll leave with a repeatable methodology, working code examples, and a mental model for thinking about performance at a system architecture level.

Why ABAP Performance Still Matters in the HANA Era

A common misconception I hear from teams moving to S/4HANA is: “HANA is so fast, we don’t need to worry about performance anymore.” That’s dangerously wrong. HANA changes where your bottleneck lives, but it doesn’t eliminate bottlenecks. You can absolutely write catastrophically slow ABAP on HANA if you’re not thinking architecturally.

The good news is that many of the principles remain the same. The bad news is that some patterns that were merely suboptimal on classic databases become actively harmful on HANA because they prevent the optimizer from pushing logic down to the database layer. More on that shortly.

Before we dive into solutions, let’s establish a diagnostic framework. Blindly optimizing without measurement is how you waste two weeks making the wrong thing 10% faster.

Step 1: Measure Before You Optimize — The SAT and Runtime Analysis Tools

The single most important rule in performance work is: never guess, always measure. SAP provides excellent built-in tooling that most developers underutilize.

ABAP Profiler (SAT)

Transaction SAT (the ABAP Trace) is your first stop. It gives you a hierarchical breakdown of where runtime is actually being spent, down to individual statements and method calls. Run it in the environment that exhibits the problem—ideally a production-representative system with realistic data volumes.

What to look for in SAT output:

  • Database time vs. ABAP time ratio: If >70% of time is in DB calls, your problem is SQL. If it’s in ABAP processing, look at loops, string operations, and function call overhead.

  • Hit counts: A SELECT statement executed 50,000 times inside a loop is a classic N+1 problem.

  • Self time hotspots: Methods with disproportionately high self-time are prime candidates for optimization.

SQL Trace (ST05)

For database-heavy programs, complement SAT with ST05. This shows you the actual SQL being generated, execution times, and critically—whether the database is doing full table scans. If you see a SELECT with no WHERE clause filtering on an indexed column, that’s your smoking gun.

HANA-Specific: EXPLAIN PLAN and PlanViz

On S/4HANA systems, open the SAP HANA cockpit or use the SQL console in HANA Studio to run EXPLAIN PLAN on your generated SQL. This reveals whether HANA is using indexes, performing merge joins, or—worst case—spilling to disk.

Step 2: The Most Common ABAP Performance Anti-Patterns

After years of reviewing ABAP codebases, certain patterns appear again and again. Let me walk you through the most impactful ones.

Anti-Pattern 1: SELECT Inside a LOOP (The N+1 Problem)

This is the number one performance killer in ABAP systems. It looks innocent in small-data scenarios and devastating at scale.


" ❌ WRONG APPROACH — N+1 SELECT anti-pattern
SELECT * FROM vbak INTO TABLE @DATA(lt_orders)
  WHERE erdat = @sy-datum.

LOOP AT lt_orders INTO DATA(ls_order).
  " This executes one SELECT for EVERY order — catastrophic at scale
  SELECT SINGLE * FROM vbap INTO @DATA(ls_item)
    WHERE vbeln = @ls_order-vbeln.
  " process ls_item...
ENDLOOP.

The fix: use a JOIN or a secondary SELECT with FOR ALL ENTRIES — but use FOR ALL ENTRIES carefully. It has its own gotchas.


" ✅ CORRECT APPROACH — Single JOIN or buffered secondary select

" Option A: JOIN approach (preferred when relationship is straightforward)
SELECT vbak~vbeln, vbak~kunnr, vbap~posnr, vbap~matnr, vbap~kwmeng
  FROM vbak
  INNER JOIN vbap ON vbap~vbeln = vbak~vbeln
  INTO TABLE @DATA(lt_order_items)
  WHERE vbak~erdat = @sy-datum.

" Option B: FOR ALL ENTRIES (use when JOIN is impractical)
" CRITICAL: Always check that the driving internal table is NOT EMPTY
" before using FOR ALL ENTRIES — an empty table fetches ALL records!
IF lt_orders IS NOT INITIAL.
  SELECT * FROM vbap
    INTO TABLE @DATA(lt_items)
    FOR ALL ENTRIES IN @lt_orders
    WHERE vbeln = @lt_orders-vbeln.
ENDIF.

Note that FOR ALL ENTRIES cannot be combined with ORDER BY, DISTINCT, or aggregate functions. It also deduplicates the driving table implicitly. Know these constraints cold.

Anti-Pattern 2: Unnecessary Data Transfer — SELECT *

Fetching all columns when you need three is wasteful both in network transfer and memory allocation. This becomes particularly problematic on wide tables like BSEG or KONV.


" ❌ Fetches all ~200 columns of BSEG across the network
SELECT * FROM bseg INTO TABLE @DATA(lt_bseg)
  WHERE bukrs = '1000' AND gjahr = '2024'.

" ✅ Fetch only what you need
SELECT bukrs, belnr, gjahr, buzei, hkont, dmbtr, shkzg
  FROM bseg
  INTO TABLE @DATA(lt_bseg_slim)
  WHERE bukrs = '1000' AND gjahr = '2024'.

On HANA, column-store architecture means this matters even more—the engine only reads the columns you specify from storage, which has real I/O implications at scale.

Anti-Pattern 3: Inefficient Internal Table Operations

Internal table performance is often overlooked, but with large datasets it’s significant. The key principle: use the right table type for the right access pattern.


" Table type selection guide:

" STANDARD TABLE — O(n) linear search. OK for small tables or sequential processing.
" Use SORT + BINARY SEARCH for repeated keyed reads.
DATA: lt_standard TYPE STANDARD TABLE OF mara WITH DEFAULT KEY.

" SORTED TABLE — O(log n) access by key. Good for read-heavy workloads with known key.
DATA: lt_sorted TYPE SORTED TABLE OF mara
  WITH UNIQUE KEY matnr.

" HASHED TABLE — O(1) access by key. Best for large lookup tables with unique keys.
DATA: lt_hashed TYPE HASHED TABLE OF mara
  WITH UNIQUE KEY matnr.

" ❌ Common mistake: LOOP + READ TABLE LINEAR on a large standard table
LOOP AT lt_large_table INTO DATA(ls_item).
  READ TABLE lt_lookup INTO DATA(ls_ref)
    WITH KEY field1 = ls_item-field1. " O(n) each time = O(n²) total
ENDLOOP.

" ✅ Use HASHED TABLE for the lookup to get O(n) total
DATA: lt_hashed_lookup TYPE HASHED TABLE OF ty_ref
  WITH UNIQUE KEY field1.
" Populate lt_hashed_lookup once, then:
LOOP AT lt_large_table INTO DATA(ls_item).
  READ TABLE lt_hashed_lookup INTO DATA(ls_ref)
    WITH TABLE KEY field1 = ls_item-field1. " O(1) each time = O(n) total
ENDLOOP.

Anti-Pattern 4: Missing or Misused Indexes

Always verify that your WHERE clause conditions align with existing database indexes. In transaction SE11, you can inspect table technical settings and index definitions. A WHERE clause that doesn’t lead with an indexed column—especially the first field of a composite index—will trigger a full table scan.

One nuance that bites people on HANA: HANA’s column store handles non-indexed reads better than traditional row-store databases, but this doesn’t mean you should ignore indexes. For high-frequency transactional reads, a well-placed secondary index still makes a dramatic difference.

Step 3: Pushing Logic to the Database — Code-to-Data vs. Data-to-Code

This is where HANA-era thinking diverges most sharply from classic ABAP patterns. The guiding principle in modern SAP development is code-to-data: let the database engine do the heavy lifting rather than fetching raw data and processing it in ABAP.

If you’ve read our article on ABAP CDS Views for High-Performance Data Modeling, you already know that CDS views are a central vehicle for this paradigm. Instead of fetching millions of rows and aggregating them in ABAP, push the aggregation into a CDS view or an AMDP (ABAP Managed Database Procedure).


" ❌ Data-to-code: Fetch everything, aggregate in ABAP
SELECT * FROM vbrp INTO TABLE @DATA(lt_billing_items)
  WHERE fkdat BETWEEN @lv_start AND @lv_end.

DATA: lv_total TYPE p DECIMALS 2.
LOOP AT lt_billing_items INTO DATA(ls_item).
  lv_total += ls_item-netwr.
ENDLOOP.

" ✅ Code-to-data: Let the database aggregate
SELECT SUM( netwr ) AS total_revenue
  FROM vbrp
  INTO @DATA(lv_db_total)
  WHERE fkdat BETWEEN @lv_start AND @lv_end.

Extend this thinking to GROUP BY, HAVING, subqueries, and window functions (supported in Open SQL on HANA). The database is extraordinarily good at set-based operations. ABAP loops, by contrast, are inherently sequential.

Step 4: Buffering Strategies — When and How to Cache

Not all performance problems are about raw SQL efficiency. Sometimes the answer is to read data less frequently through intelligent buffering.

SAP Table Buffering

For configuration and master data tables that are read frequently but change rarely, SAP’s built-in table buffering (configured in SE11 → Technical Settings) can eliminate database roundtrips entirely by serving data from the application server’s shared memory. Suitable candidates: T-code control tables, configuration entries, country/currency tables.

Be aware of the synchronization lag: buffered tables have a configurable synchronization interval. For transactional data that must be current, buffering is inappropriate.

Application-Level Caching with Shared Objects

For computed data that’s expensive to derive but reused across multiple requests or users, SAP Shared Objects (transaction SHMA) provides a shared memory area accessible across sessions. This is advanced territory but extremely powerful for lookup-heavy scenarios.


" Conceptual pattern for shared object caching
" (Full implementation requires area class and root class definitions)
CLASS lcl_material_cache DEFINITION.
  PUBLIC SECTION.
    CLASS-METHODS: get_instance RETURNING VALUE(ro_instance) TYPE REF TO lcl_material_cache.
    METHODS: get_material
      IMPORTING iv_matnr TYPE matnr
      RETURNING VALUE(rs_material) TYPE mara
      RAISING cx_sy_ref_is_initial.
  PRIVATE SECTION.
    DATA: mt_cache TYPE HASHED TABLE OF mara WITH UNIQUE KEY matnr.
ENDCLASS.

CLASS lcl_material_cache IMPLEMENTATION.
  METHOD get_material.
    " Check in-memory cache first
    READ TABLE mt_cache INTO rs_material WITH TABLE KEY matnr = iv_matnr.
    IF sy-subrc <> 0.
      &quot; Cache miss: fetch from DB and store
      SELECT SINGLE * FROM mara INTO rs_material
        WHERE matnr = iv_matnr.
      IF sy-subrc = 0.
        INSERT rs_material INTO TABLE mt_cache.
      ENDIF.
    ENDIF.
  ENDMETHOD.
ENDCLASS.

Step 5: Parallel Processing for CPU-Bound Workloads

When your bottleneck is pure ABAP processing time rather than database I/O, consider parallel processing using ABAP’s background task framework or CALL FUNCTION … STARTING NEW TASK.


&quot; Parallel processing pattern using asynchronous RFC
&quot; Partition work into chunks, dispatch each chunk to a parallel work process

DATA: lt_partitions TYPE TABLE OF ty_partition,
      lv_task       TYPE string.

&quot; Build partitions of your workload
&quot; (implementation of partition logic omitted for brevity)

DATA(lv_counter) = 0.
LOOP AT lt_partitions INTO DATA(ls_partition).
  lv_counter += 1.
  lv_task = |TASK_{ lv_counter }|.

  CALL FUNCTION &#039;Z_MY_PARALLEL_WORKER&#039;
    STARTING NEW TASK lv_task
    DESTINATION IN GROUP DEFAULT
    PERFORMING on_task_complete ON END OF TASK
    EXPORTING
      is_partition = ls_partition.

  &quot; Handle resource limits gracefully
  IF sy-subrc <> 0.
    &quot; Fall back to synchronous processing for this partition
    CALL FUNCTION &#039;Z_MY_PARALLEL_WORKER&#039;
      EXPORTING is_partition = ls_partition.
  ENDIF.
ENDLOOP.

&quot; Wait for all async tasks to complete
WAIT UNTIL lv_counter = 0 UP TO 300 SECONDS.

A word of caution from experience: parallel processing introduces complexity around error handling and result aggregation. Only reach for it when profiling clearly shows ABAP CPU time as your bottleneck and the work is naturally parallelizable without shared state conflicts. Also check with your basis team about available work process capacity before enabling this in production.

Putting It All Together: A Performance Review Checklist

When you’re reviewing ABAP code for performance—your own or a colleague’s—run through this checklist systematically:

  • Profile first: Use SAT and ST05 to identify the actual bottleneck before touching any code.

  • Eliminate N+1 queries: No SELECTs inside loops. Use JOINs or FOR ALL ENTRIES (with the empty-check guard).

  • Select only needed columns: Never use SELECT * on wide tables in production code.

  • Align WHERE clauses with indexes: Verify in SE11 that your filter fields have index coverage.

  • Choose the right internal table type: HASHED for lookup, SORTED for ranged reads, STANDARD for sequential processing.

  • Push logic to the database: Aggregations, filters, joins—let the database engine handle these.

  • Consider buffering: Stable master/config data should leverage table buffering or application caching.

  • Parallelize only when warranted: Profile-driven decision, not premature optimization.

This connects closely to the clean code principles I discussed in ABAP Clean Code: 10 Golden Rules for Readable and Maintainable SAP Code—performance and readability aren’t in conflict when you understand both deeply. And if you’re working with modern CDS-based architectures, the data modeling decisions covered in ABAP CDS Views for High-Performance Data Modeling directly impact the performance patterns discussed here.

Conclusion

Performance optimization in ABAP is a discipline, not a bag of tricks. The developers and architects who consistently deliver fast, scalable SAP solutions aren’t the ones who memorize every optimization hack—they’re the ones who measure rigorously, understand their data access patterns, and choose the right tool for each situation.

The patterns in this article—eliminating N+1 queries, selecting only needed data, choosing appropriate table types, and embracing the code-to-data paradigm—will address the majority of real-world performance issues you’ll encounter. Start with measurement, fix the biggest bottleneck first, and measure again. That feedback loop is more valuable than any single optimization technique.

As your systems grow and data volumes increase, revisit these fundamentals regularly. What performs acceptably at 100,000 records can become critically slow at 10 million. Build performance awareness into your code review culture now, and you’ll spend far less time firefighting in production later.

What’s Next?

In a follow-up article, we’ll dive into ABAP Unit Testing and Test-Driven Development—because the discipline of writing testable code is deeply connected to writing performant, maintainable code. A well-structured, decoupled ABAP program is almost always easier to optimize than a tightly coupled monolith.

Have you encountered a particularly tricky ABAP performance problem in your work? Drop it in the comments below. I read every one, and real-world war stories often make the best follow-up content. If this article saved you debugging time, share it with your team—performance awareness is most effective when it’s a team culture, not an individual habit.

JWTs in Elixir: fast parsing by plain pattern matching

JSON Web Tokens (JWTs) are the backbone of modern authentication. If you are building an API, chances are you are verifying a JWT on almost every single incoming request.

Because it happens so frequently, JWT verification is a prime candidate for optimization. But if you look at how standard JWT verification works, there is a lot of hidden overhead.

The Standard “Slow Path”

A JWT consists of three parts separated by dots: header.payload.signature. To verify a standard token, your application typically has to do the following:

  1. Split the string by the . character using String.split/2 and/or regex.
  2. Base64-decode the header.
  3. Parse the resulting JSON string into a map.
  4. Extract the kid (Key ID) and alg (Algorithm) claims.
  5. Look up the correct public or symmetric key.
  6. Verify the signature against the payload.

Steps 1 through 4 require allocating memory for new binaries, running a Base64 decoder, and firing up a JSON parser—all just to figure out which key to use.

If your application is minting its own tokens, 99% of the incoming requests are going to use your current, active signing key. Doing all that decoding and parsing on every request is wasted effort.

Our own header is always the same

The realization that speeds this up is simple: if you control the active signing key, the JWT header is entirely predictable.

Instead of extracting the token’s header to find the key, we can flip the process upside down. We can pre-calculate exactly what the Base64-encoded header will look like for our active key, and use it to fast-track both signing and verifying tokens.

Setup: Caching the Header

The first step is to calculate the expected header at startup (or upon key rotation) and cache it. We use Erlang’s :persistent_term for blazing fast, concurrent read access.

defmodule MyApp.Jwt do
  @doc "Run this at application startup or when rotating keys"
  def setup_fast_path(kid, alg \ "HS256") do
    expected_header =
      # we need deterministic JSON with predictable key ordering
      ~s({"alg":"#{alg}","typ":"JWT","kid":"#{kid}"})
      |> Base.url_encode64(padding: false)

    :persistent_term.put(:jwt_header, expected_header)
  end
end

Fast-track verification

The real magic happens when checking incoming requests. We use Elixir’s binary pattern matching to instantly route the token based on whether it starts with our cached header:

def verify(token) do
  expected_header = :persistent_term.get(:jwt_header, nil)

  case token do
    # Does the token start with our exact pre-calculated header?
    <<^expected_header::binary, ?., payload_and_sig::binary>> -> 
      fast_verify(payload_and_sig)

    # ... slow path fallback ...
    _ ->
      slow_verify(token)
  end
end

The binary pattern matching is Elixir at its finest. The VM doesn’t need to split strings or allocate new memory to do so. It simply looks at the bytes of the incoming token, checks if they match the pinned expected_header variable followed by a . character, and assigns the rest of the binary to payload_and_sig. We now know the signing key and algorithm without parsing the header!

We are not done, however, and can take pattern matching one step further to split the payload and signature. This relies on two things: we know the signature’s length in advance and can use byte_size/1 to determine the length of the whole (in constant time!):

# For HS256, the encoded signature is always exactly 43 characters
@hs256_sig_len 43

def fast_verify(payload_and_sig) do
  payload_len = byte_size(payload_and_sig) - @hs256_sig_len - 1

  case payload_and_sig do
    <<enc_payload::binary-size(payload_len), ?., enc_sig::binary-size(@hs256_sig_len)>> ->
      # We skipped String.split/2 entirely!
      # Now we just decode the payload and verify the signature...
      do_crypto_verify(enc_payload, enc_sig)

    _ ->
      {:error, :malformed_token}
  end
end

Signing

Of course, we can also use our precomputed header to speed up token signing (also leveraging iolists to prevent creating unnecessary intermediate binaries):

def sign(payload, key) do
  header = :persistent_term.get(:jwt_header, nil)
  enc_payload = payload |> JSON.encode!() |> Base.url_encode64(padding: false)
  mac_base = [header, ?., enc_payload]
  sig = :crypto.mac(:hmac, :sha256, key, mac_base) |> Base.url_encode64(padding: false)
  IO.iodata_to_binary([mac_base, ?., sig])
end

What if the header is not fully deterministic? Poly1305 and Base64 Magic

The optimization above works flawlessly for algorithms like HS256 or EdDSA. But what if you are using a MAC generated via Poly1305?

Poly1305 requires a unique nonce for every single invocation to remain secure. This means our JWT header suddenly needs to look like this:
{"alg":"Poly1305","typ":"JWT","nonce":"<random_string>","kid":"my_key"}

Because the nonce changes every time, we can no longer pre-calculate the entire Base64 encoded header. Does this mean we have to fall back to the slow path? Not if we use a little Base64 math.

Base64 works by encoding 3 bytes of raw data into 4 characters. If a string’s byte length is exactly a multiple of 3, it encodes cleanly without padding (=) and without carrying over bits into the next character block. Such encoded strings can then be safely concatenated without invalidating the encoding!

We can exploit this by carefully crafting our JSON header so the static parts are multiples of 3 bytes:

  • Part 1: {"alg":"Poly1305","typ":"JWT","nonce":" (Length is 39, and 39 / 3 = 13. Perfect!)
  • Part 2: <dynamic_nonce>", (The nonce is 12 bytes, which encodes to 16 bytes. We add ", and part 2 is now 18 bytes – dividable by 3!)
  • Part 3: "kid": (Length is 6!)
  • Part 4: "my_key"} (The last part has no length requirement because nothing will be concatenated to its tail)

Because Parts 1, 2 and 3 are multiples of 3, we can safely pre-calculate their Base64 equivalents and concatenate them with the encoded dynamic bits later:

# Pre-encoded static chunks (safe to concatenate)
@p1305_h ~s({"alg":"Poly1305","typ":"JWT","nonce":") |> Base.url_encode64(padding: false)
@p1305_kid_seg ~s("kid":) |> Base.url_encode64(padding: false)

def setup_poly1305_fast_path(kid) do
  exp_htail = ~s("#{kid}"}) |> Base.url_encode64(padding: false)
  :persistent_term.put(:jwt_p1305_header, exp_htail)
end

Now, we can add a new pattern match to our verify/1 function to catch Poly1305 tokens. We just extract the encoded nonce_seg right out of the middle of the binary string:

def verify(token) do
  exp_header = :persistent_term.get(:jwt_header, nil)
  exp_htail  = :persistent_term.get(:jwt_p1305_header, nil)

  case token do
    # 1. The Simple Fast Path (HS256, etc)
    <<^exp_header::binary, ?., payload_and_sig::binary>> -> 
      fast_verify(payload_and_sig)

    # 2. The Base64 Magic Fast Path (Poly1305)
    <<@p1305_h, nonce_seg::binary-24, @p1305_kid_seg, ^exp_htail::binary, ?., payload_and_sig::binary>> ->
      fast_p1305_verify(nonce_seg, payload_and_sig)

    # 3. THE SLOW PATH
    _ -> 
      slow_verify(token)
  end
end

By ensuring our JSON keys and values add up to multiples of 3, we can continue to use Elixir’s blazing fast binary pattern matching to route tokens, skipping the JSON parser entirely even when parts of the header are highly dynamic.

The Fallback

Of course, the slow path cannot be completely removed. If you rotate your signing keys, users with valid tokens signed by the old key will still need to access your API.

Because their tokens will have a different kid in the header, the fast path pattern match will naturally fail. The case statement falls back to slow_verify, which does the traditional Base64 decoding, JSON parsing, and dynamic key lookup.

The beauty of this architecture is that the fast path handles 99% of your traffic with near-zero overhead, while the slow path gracefully catches edge cases, legacy tokens, and key rotations without the caller ever needing to know the difference. And although we don’t parse the headers, we don’t assume anything here; because of the binary pattern matching, there’s not a single header byte that is not exactly what we expect it to be. JWT-verification speed is improved by about 30% for normal-sized JWTs.

A real-world example (using only OTP!) that supports key rotation and multiple signing algorithms including HMAC-SHA256, EdDSA and Poly1305 can be found in Charon’s JWT token factory.

SAP BTP Event Mesh & Event-Driven Architecture: Building Resilient Integrations That Actually Scale

If you’ve spent any significant time working on SAP integration projects, you know the pain: point-to-point connections that grow into unmaintainable spaghetti, synchronous calls that create cascading failures, and integration landscapes that buckle under load. I’ve seen enterprise systems brought to their knees not by complex business logic, but by brittle, tightly-coupled integration patterns. That’s exactly why SAP BTP Event Mesh and event-driven architecture deserve serious attention from every architect working in the SAP ecosystem today.

In this article, I want to go beyond the marketing slides. We’ll look at what event-driven architecture really means in an SAP context, when it genuinely solves your problems, and how to implement it pragmatically using SAP Business Technology Platform’s Event Mesh service. By the end, you’ll have concrete patterns and code you can start applying in your own landscape.

Why Point-to-Point Integration Is Quietly Killing Your Architecture

Let’s be honest about something most consultants won’t say in a kickoff meeting: most SAP integration landscapes are architectural accidents, not architectural decisions. They grew organically—one RFC call here, one IDoc there, a SOAP service added because someone needed it urgently. Before long, you’re maintaining hundreds of direct connections with no clear ownership.

The fundamental problem is tight coupling. When System A calls System B synchronously:

  • A’s availability depends on B’s availability

  • A’s performance depends on B’s response time

  • Any change in B’s interface requires coordinated changes in A

  • Scaling A independently of B is practically impossible

Event-driven architecture inverts this dependency. Instead of System A calling System B, System A announces that something happened. System B (and C, and D) decide independently whether they care about that event. The producer doesn’t know or care about consumers. That’s a fundamentally different—and far more resilient—way to build integrations.

“Decoupling is not a nice-to-have. In distributed enterprise systems, it’s the difference between a system that recovers from failure gracefully and one that fails completely.”

Understanding SAP BTP Event Mesh: The Basics You Need

SAP BTP Event Mesh is a fully managed, cloud-based messaging service that acts as the central broker in your event-driven landscape. It’s built on open standards—AMQP 1.0 and MQTT—which means it’s not a proprietary black box you’ll regret in five years.

Here are the core concepts you need to internalize before designing anything:

Topics vs. Queues

Topics support a publish-subscribe model. A producer publishes an event to a topic, and any number of subscribers receive it. This is ideal for broadcasting business events like sap/s4/BusinessPartner/Changed.

Queues support point-to-point or competing consumers. Messages persist until consumed. Use queues when you need guaranteed delivery and exactly-once processing semantics.

In practice, the most robust pattern combines both: publish events to a topic, then create queue subscriptions that bind to specific topic patterns. This gives you broadcast capability and guaranteed delivery.

Event Mesh Namespaces and Topic Naming Conventions

This is where I see architects make costly mistakes early on. Topic names in Event Mesh follow a hierarchical structure, and SAP recommends a specific convention for business events:

<namespace>/<source-type>/<source-name>/<event-type>/<version>

# Example:
mycompany/sap/s4hana/BusinessPartner/Created/v1
mycompany/custom/warehouseapp/StockLevel/Updated/v2

Define this naming convention before you start building. Retrofitting topic names across a live system is genuinely painful—trust me on this one.

The SAP S/4HANA Event-Driven Integration Pattern

SAP S/4HANA natively supports outbound event publishing via its Business Event Handling framework. When a business object changes—a Business Partner is created, a Sales Order is posted—S/4HANA can publish a structured CloudEvent directly to SAP BTP Event Mesh.

Configuring S/4HANA Outbound Bindings

The configuration path in S/4HANA is: SAP Fiori → Enterprise Event Enablement → Channel Bindings. You’ll create a channel pointing to your Event Mesh service instance, then configure topic bindings for specific business events.

The events S/4HANA publishes conform to the CloudEvents 1.0 specification—an important detail because it means your consumers can be built with any language or framework that understands this open standard.

A typical CloudEvent payload from S/4HANA looks like this:

{
  &quot;specversion&quot;: &quot;1.0&quot;,
  &quot;type&quot;: &quot;sap.s4.beh.businesspartner.v1.BusinessPartner.Created.v1&quot;,
  &quot;source&quot;: &quot;/default/sap.s4.beh/MYCLNT&quot;,
  &quot;id&quot;: &quot;a7c8f2e1-4b3d-4a9e-8c1d-2f5e3a7b9c0d&quot;,
  &quot;time&quot;: &quot;2025-03-25T14:30:00Z&quot;,
  &quot;datacontenttype&quot;: &quot;application/json&quot;,
  &quot;data&quot;: {
    &quot;BusinessPartner&quot;: &quot;1000012345&quot;
  }
}

Notice something important: the payload contains only the key of the changed object, not its full state. This is called the notification event pattern. The consumer receives a notification that something changed, then queries the source system (via OData API) to retrieve the full, current state. This avoids data consistency issues when events are processed out of order.

Building an Event Consumer: Python Example with SAP BTP

Let’s get practical. Here’s how you’d build a Python-based event consumer that listens to Business Partner creation events and processes them. This would run as a Cloud Foundry application or Kyma workload on SAP BTP.

# requirements.txt
# solace-pubsubplus==1.7.0
# requests==2.31.0
# python-dotenv==1.0.0

import os
import json
import logging
import requests
from solace.messaging.messaging_service import MessagingService
from solace.messaging.resources.queue import Queue
from solace.messaging.receiver.message_receiver import MessageHandler
from dotenv import load_dotenv

load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class BusinessPartnerEventHandler(MessageHandler):
    &quot;&quot;&quot;
    Handles incoming Business Partner events from SAP Event Mesh.
    Implements the notification-then-fetch pattern.
    &quot;&quot;&quot;

    def __init__(self, s4_base_url: str, oauth_token: str):
        self.s4_base_url = s4_base_url
        self.oauth_token = oauth_token

    def on_message(self, message):
        try:
            payload = json.loads(message.get_payload_as_string())
            logger.info(f&quot;Received event: {payload.get(&#039;type&#039;)}&quot;)

            # Extract the Business Partner key from the event
            bp_id = payload.get(&#039;data&#039;, {}).get(&#039;BusinessPartner&#039;)
            if not bp_id:
                logger.warning(&quot;Event received with no BusinessPartner key. Skipping.&quot;)
                return

            # Fetch full Business Partner data from S/4HANA OData API
            bp_data = self._fetch_business_partner(bp_id)
            if bp_data:
                self._process_business_partner(bp_data)

        except json.JSONDecodeError as e:
            logger.error(f&quot;Failed to parse event payload: {e}&quot;)
        except Exception as e:
            logger.error(f&quot;Unexpected error processing event: {e}&quot;)
            # In production: implement dead-letter queue logic here
            raise  # Re-raise to trigger redelivery

    def _fetch_business_partner(self, bp_id: str) -> dict | None:
        &quot;&quot;&quot;
        Fetch current Business Partner state from S/4HANA OData API.
        This is the &#039;notification-then-fetch&#039; pattern in action.
        &quot;&quot;&quot;
        url = f&quot;{self.s4_base_url}/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner(&#039;{bp_id}&#039;)&quot;
        headers = {
            &quot;Authorization&quot;: f&quot;Bearer {self.oauth_token}&quot;,
            &quot;Accept&quot;: &quot;application/json&quot;
        }

        try:
            response = requests.get(url, headers=headers, timeout=10)
            response.raise_for_status()
            return response.json().get(&#039;d&#039;)
        except requests.exceptions.Timeout:
            logger.error(f&quot;Timeout fetching BP {bp_id}. Will retry via redelivery.&quot;)
            raise  # Trigger message redelivery
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 404:
                logger.warning(f&quot;BP {bp_id} not found—may have been deleted. Ignoring.&quot;)
                return None
            raise

    def _process_business_partner(self, bp_data: dict):
        &quot;&quot;&quot;
        Your actual business logic goes here.
        Examples: sync to downstream CRM, update MDM system, trigger workflow.
        &quot;&quot;&quot;
        bp_id = bp_data.get(&#039;BusinessPartner&#039;)
        bp_name = bp_data.get(&#039;BusinessPartnerFullName&#039;)
        bp_category = bp_data.get(&#039;BusinessPartnerCategory&#039;)

        logger.info(f&quot;Processing BP: {bp_id} | Name: {bp_name} | Category: {bp_category}&quot;)
        # TODO: Add your downstream system integration here

def create_messaging_service() -> MessagingService:
    &quot;&quot;&quot;Creates and connects the Solace messaging service for Event Mesh.&quot;&quot;&quot;
    broker_props = {
        &quot;solace.messaging.transport.host&quot;: os.environ[&quot;EVENT_MESH_HOST&quot;],
        &quot;solace.messaging.service.vpn-name&quot;: os.environ[&quot;EVENT_MESH_VPN&quot;],
        &quot;solace.messaging.authentication.scheme.basic.username&quot;: os.environ[&quot;EVENT_MESH_USER&quot;],
        &quot;solace.messaging.authentication.scheme.basic.password&quot;: os.environ[&quot;EVENT_MESH_PASSWORD&quot;],
    }
    service = MessagingService.builder().from_properties(broker_props).build()
    service.connect()
    return service

def main():
    logger.info(&quot;Starting Business Partner Event Consumer...&quot;)

    messaging_service = create_messaging_service()
    handler = BusinessPartnerEventHandler(
        s4_base_url=os.environ[&quot;S4_BASE_URL&quot;],
        oauth_token=os.environ[&quot;S4_OAUTH_TOKEN&quot;]
    )

    queue_name = os.environ[&quot;EVENT_MESH_QUEUE_NAME&quot;]  # e.g., &quot;bp-created-consumer&quot;
    queue = Queue.durable_exclusive_queue(queue_name)

    receiver = messaging_service.create_persistent_message_receiver_builder()
        .with_message_auto_acknowledgement()
        .build(queue)

    receiver.start()
    receiver.receive_async(handler)

    logger.info(f&quot;Listening on queue: {queue_name}&quot;)
    logger.info(&quot;Press Ctrl+C to stop.&quot;)

    try:
        import time
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        logger.info(&quot;Shutting down consumer...&quot;)
        receiver.terminate()
        messaging_service.disconnect()

if __name__ == &quot;__main__&quot;:
    main()

A few things worth highlighting in this code:

  • Re-raise on transient errors: Timeout errors cause the exception to propagate, which prevents acknowledgement and triggers redelivery. This is correct behavior for infrastructure failures.

  • Swallow 404s: If an object was deleted before your consumer processed the creation event (rare but possible), return gracefully rather than crashing.

  • Secrets from environment: Never hardcode credentials. In BTP Cloud Foundry, these come from service bindings; in Kyma, from Kubernetes secrets.

Critical Architectural Decisions You’ll Face

Decision 1: Notification Events vs. Event-Carried State Transfer

I touched on this earlier, but it deserves deeper treatment. You have two options:

Notification event: Publish only the key. Consumer fetches current state on demand. This is SAP’s default pattern and works well when consumers need fresh data and the source system is reliable.

Event-Carried State Transfer (ECST): Publish the full state snapshot in the event payload. Consumers process it without calling back. This works better for high-throughput scenarios where you want to minimize API calls, but risks stale data if events are processed out of order.

My recommendation: start with notification events for SAP-sourced data. Move to ECST only if you have measured performance problems with the fetch-on-demand approach.

Decision 2: At-Least-Once vs. Exactly-Once

Event Mesh guarantees at-least-once delivery. This means your consumers must be idempotent—processing the same event twice should produce the same result as processing it once. Design for this from day one, not as an afterthought.

A simple idempotency strategy: maintain a processed-events table keyed by CloudEvent id. Check before processing; insert after. This adds latency but prevents duplicate processing.

Decision 3: Error Handling and Dead Letter Queues

Define your dead letter queue (DLQ) strategy before going live. In Event Mesh, you configure maximum redelivery attempts on a queue. After those attempts, the message lands in a DLQ. You need:

  • A monitored DLQ per consumer queue

  • Alerting when DLQ depth exceeds a threshold

  • A replay mechanism for batch reprocessing after fixes

This is operational discipline, not just architecture. Skipping it will cost you a late-night incident eventually.

Event Mesh and SAP Integration Suite: How They Fit Together

If you’re working with SAP Integration Suite (formerly Cloud Platform Integration), you can use the AMQP Sender and Receiver adapters to connect integration flows to Event Mesh. This creates a powerful hybrid: event-driven triggering combined with Integration Suite’s rich transformation and orchestration capabilities.

A common pattern I’ve implemented:

  • S/4HANA publishes a Business Partner Changed event to Event Mesh

  • An Integration Suite iFlow subscribes to the queue

  • The iFlow fetches full data from the S/4HANA OData API, transforms it, and calls a downstream REST API

  • On failure, Integration Suite’s error handling framework manages retries and alerting

This keeps your integration logic in Integration Suite (where your team already has tooling and expertise) while gaining the resilience benefits of event-driven triggering. You don’t have to choose one or the other.

If you’re also exploring how RPA and AI automation integrate with SAP BTP’s broader architecture, you might find the approach we discussed in SAP BTP ile Akıllı Süreç Otomasyonu: RPA ve AI Entegrasyonunda Mimari Kararlar relevant—many of the same decoupling principles apply when triggering automation from events.

Monitoring and Observability: Don’t Skip This

An event-driven system without proper observability is a debugging nightmare. At minimum, implement:

  • Distributed tracing: Propagate the CloudEvent id as a correlation ID through your entire processing chain. This lets you trace a single business event across multiple services.

  • Queue depth monitoring: Sustained queue depth growth means your consumers can’t keep up. Alert on this before it becomes a crisis.

  • Consumer lag metrics: Track the age of the oldest unprocessed message. Stale messages indicate processing problems.

  • DLQ depth alerting: Any message in a DLQ needs investigation. Zero tolerance here.

SAP BTP Event Mesh exposes metrics through SAP Cloud ALM and the BTP Cockpit. Integrate these into your existing monitoring stack, whether that’s Dynatrace, Grafana, or SAP Cloud ALM itself.

When NOT to Use Event-Driven Architecture

I’d be doing you a disservice if I only told you about the benefits. Event-driven architecture adds operational complexity. Here’s when it’s not the right choice:

  • You need an immediate response: If the user is waiting for a result, synchronous REST is simpler and more appropriate.

  • Simple, stable integrations: A single integration between two systems that rarely changes doesn’t need the overhead of an event broker.

  • Your team lacks operational maturity: Running a message broker well requires expertise. Don’t introduce Event Mesh if your team can’t support it properly.

  • Strict ordering requirements: While Event Mesh supports ordering within a partition, complex ordering guarantees add significant architectural complexity.

The mark of a good architect isn’t using sophisticated patterns everywhere—it’s knowing when simplicity serves you better.

Key Takeaways

Let me distill the most important points from everything we’ve covered:

  • Event-driven architecture solves tight coupling, which is the root cause of most integration fragility in SAP landscapes.

  • SAP BTP Event Mesh is a production-grade, standards-based broker that integrates natively with S/4HANA’s event publishing framework.

  • Use the notification-then-fetch pattern for SAP-sourced events to avoid data consistency issues.

  • Design consumers to be idempotent from day one—at-least-once delivery is guaranteed, so duplicate processing must be harmless.

  • Invest in observability before you go live: distributed tracing, queue monitoring, and DLQ alerting are non-negotiable in production.

  • Choose synchronous patterns when appropriate—event-driven is not universally superior, just suited to specific problems.

Event-driven architecture is one of those shifts that feels abstract until you’ve lived through a cascading failure caused by synchronous coupling. After that experience, the value of decoupling becomes viscerally clear. Start small—pick one high-value integration, implement it with Event Mesh, and let the operational experience inform your broader strategy.

What’s Your Experience?

Have you implemented event-driven patterns in your SAP landscape? I’d love to hear what challenges you encountered—particularly around idempotency, ordering, and team adoption. Drop your thoughts in the comments below, and if you found this useful, share it with your team. These are the conversations that help our entire community build better systems.

How AgentTalk Uses Wallet Attestation for Agent-to-Agent Sessions

AI agents are starting to transact with each other. They negotiate deals, exchange data, and move money. But how does one agent know the other is worth talking to? AgentTalk, built by Skye Meta, answers that question with on-chain wallet conditions verified by InsumerAPI.

The Problem: Agents Need Trust Before Communication

When two AI agents meet on a network, they face a fundamental question. Should I talk to this thing? Passwords prove you know a secret. API keys prove you have access. Neither proves financial capacity, community membership, or on-chain reputation.

An agent negotiating a supply chain deal needs to know its counterpart controls real assets. An agent operating inside a DAO needs to verify the other agent actually holds governance tokens. A compliance-gated data exchange needs proof that EAS credentials are on-chain, not just claimed.

Static credentials do not solve this. A password issued yesterday says nothing about what the agent holds today. On-chain state changes continuously. The trust primitive has to be dynamic, verifiable, and composable.

How AgentTalk Works

AgentTalk is a condition-gated communication protocol for AI agents. Every channel has wallet conditions. Every session is verified on-chain. The flow has five steps.

        - **Declare.** The channel creator defines conditions: which tokens, which chains, what thresholds. Up to 10 conditions per channel across 32 chains (30 EVM + Solana + XRPL).
        - **Connect.** An agent joins the channel and presents its wallet address.
        - **Attest.** AgentTalk calls InsumerAPI (`POST /v1/attest`) to verify both wallets against the channel conditions. Boolean results. Pass or fail.
        - **Session.** If both wallets pass, each agent receives an ECDSA-signed attestation JWT. The agents can now communicate.
        - **Verify.** At any point during the session, either agent can request re-verification. AgentTalk calls the attest endpoint again. If the wallet no longer meets the conditions, the session ends immediately.

This is the key principle: sell the token, lose the session. Access is not static. It is continuously tied to on-chain state. The moment an agent’s wallet no longer meets the conditions, the communication channel closes. No revocation lists, no admin intervention. The blockchain is the authority.

AgentTalk exposes this flow through four endpoints on skyemeta.com: POST /api/agenttalk/declare (create a channel with conditions), POST /api/agenttalk/join (join and receive per-agent attestation JWTs), GET /api/agenttalk/session (check session status), and POST /api/agenttalk/buy-key (purchase an InsumerAPI key with USDC). Under the hood, each session call hits InsumerAPI’s POST /v1/attest for on-chain verification.

What Makes This Different

Traditional agent communication protocols rely on identity. You authenticate once, and you are in. AgentTalk replaces identity with verifiable state.

        - **Composable conditions.** A single channel can require holdings across multiple chains and token types. Hold 1,000 USDC on Ethereum and an NFT on Base and a governance token on Arbitrum. All verified in one attestation call.
        - **Boolean results, not raw amounts.** InsumerAPI returns pass or fail for each condition. The other agent never learns how much you hold. Only whether you meet the threshold.
        - **Privacy-preserving.** No wallet contents are exposed. No transaction history is shared. The attestation confirms specific conditions without revealing anything beyond the yes-or-no answer.
        - **Independently verifiable.** Every attestation is ECDSA-signed by InsumerAPI. Any agent can verify the signature offline using the public JWKS endpoint. You do not need to trust AgentTalk. You trust the cryptography.

The API Behind It

AgentTalk calls POST /v1/attest for every session establishment and every re-verification. The request includes the wallet address and an array of conditions. The response includes a boolean result for each condition, a condition hash for tamper detection, and an ECDSA signature over the entire payload.

Agents that want additional assurance can request format: "jwt" in the attest call. This returns an ES256-signed JWT alongside the standard response. The JWT is verifiable by any standard library (Kong, Nginx, Cloudflare Access, AWS API Gateway) via the JWKS endpoint at /.well-known/jwks.json.

The verification chain is simple. You send conditions in. You get cryptographically verifiable results out. No trust in the intermediary required. The attestation is signed, the signature is public-key verifiable, and the condition hash lets you confirm the conditions were not altered in transit.

Autonomous Key Purchase

Agents that use AgentTalk start with 10 free InsumerAPI calls. After that, they need their own API key. The purchase is fully autonomous.

The agent sends USDC, USDT, or BTC to the InsumerAPI payment address on any supported chain (EVM, Solana, or Bitcoin). Then it calls POST /v1/keys/buy with the transaction hash. The key is issued automatically. No human in the loop. No credit cards. No signup forms.

This matters because the whole point of agent-to-agent communication is autonomy. If an agent has to wait for a human to purchase an API key, the system is not truly autonomous. AgentTalk agents buy their own verification capacity with on-chain payments, the same way they verify each other with on-chain state.

Use Cases

        - **Supply chain negotiation.** Agent A wants to negotiate a bulk purchase. Agent B requires proof that Agent A's wallet holds sufficient USDC before opening the channel. The condition is verified on-chain. No bank statements, no credit checks.
        - **DAO-to-DAO workflows.** Two DAOs want to coordinate on a joint proposal. Each DAO's agent must prove it holds governance tokens from both organizations. Conditions are composable, so this is a single attestation call.
        - **Compliance-gated data exchange.** An agent needs to share sensitive data but only with counterparts that hold specific EAS credentials on-chain. AgentTalk verifies the credential holdings before the session opens.
        - **AI agent marketplaces.** A marketplace of AI services requires agents to hold the marketplace's service token to participate. Sell the token, lose access to the marketplace. Dynamic, not static.

Wallet Identity as a Primitive

AgentTalk treats the wallet as the agent’s fingerprint. Not an identity document, not a login credential, but a verifiable state container. What the wallet holds right now determines what the agent can do right now.

For more on wallet identity as a primitive, see Your Wallet Is Your Fingerprint on the Skye Meta blog.

The pattern is straightforward. AgentTalk defines the conditions. InsumerAPI verifies them on-chain. The attestation is cryptographically signed. Any participant can verify it independently. No centralized authority decides who gets access. The blockchain decides.

Built on InsumerAPI

    Share this article

        [

            Share on X
        ](#)
        [

            LinkedIn
        ](#)
        [

            Reddit
        ](#)


            Discord



            Copy Link

InsumerAPI is free to start. Get an API key and try it. View API Docs

Wallet Auth: Gate Any API on What a Wallet Holds

Every authentication system in production today proves who you are. OAuth proves you logged in with Google. API keys prove you registered. JWTs prove a server issued you a session. Wallet Auth proves what you own. That is a different primitive — and it points to something bigger: blockchain state converted into standard internet credentials, consumable by any system that understands a JWT. Wallet Auth does not prove control of a wallet. It proves that a wallet meets specific on-chain conditions.

What Wallet Auth is

Wallet Auth by InsumerAPI is a new category of API access control where access decisions are based on wallet state: what a wallet holds, what it has staked, what credentials it carries. Not identity. Not login sessions. On-chain facts.

The output is a standard ES256 JWT bearer token. The input is a blockchain verification across any of 32 supported chains. Between those two points, InsumerAPI reads the chain, evaluates the conditions you specify, and signs a JWT containing the results. The verifying side never touches a blockchain. That is the architectural point: blockchain data consumed without blockchain infrastructure.

Add "format": "jwt" to any POST /v1/attest request. The response includes a jwt field alongside the standard sig and kid. Same endpoint, same credit cost, one extra field.

How it works

A Wallet Auth request is identical to a standard attestation request with one addition:

Wallet address
      &#x2193;
POST /v1/attest  { format: "jwt" }
      &#x2193;
InsumerAPI reads blockchain state
      &#x2193;
ES256 JWT credential (30 min TTL)
      &#x2193;
API gateway / middleware verifies via JWKS
      &#x2193;
Access decision
curl -X POST https://api.insumermodel.com/v1/attest 
  -H "Content-Type: application/json" 
  -H "X-API-Key: YOUR_API_KEY" 
  -d '{
    "wallet": "0x1234...abcd",
    "format": "jwt",
    "conditions": [{
      "type": "token_balance",
      "contractAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      "chainId": 1,
      "threshold": 1000,
      "decimals": 6
    }]
  }'

The response includes data.jwt, a three-part ES256-signed JWT. Decode the payload and you get standard claims:

{
  "iss": "https://api.insumermodel.com",
  "sub": "0x1234...abcd",
  "jti": "ATST-A7C3E1B2D4F56789",
  "iat": 1741089600,
  "exp": 1741091400,
  "pass": true,
  "results": [{
    "condition": 0,
    "label": "USDC >= 1000",
    "type": "token_balance",
    "chainId": 1,
    "met": true,
    "evaluatedCondition": {
      "type": "token_balance",
      "contractAddress": "0xA0b8...eB48",
      "chainId": 1,
      "threshold": 1000,
      "decimals": 6
    },
    "conditionHash": "0x3a7f...",
    "blockNumber": "0x12a3b4f",
    "blockTimestamp": "2026-03-04T12:00:00Z"
  }],
  "conditionHash": ["0x3a7f..."]
}

iss is the issuer (https://api.insumermodel.com). sub is the wallet address. exp is 30 minutes from issuance. pass is the overall boolean. results contains per-condition detail including the block number at which the chain was read. conditionHash is a SHA-256 of the evaluated conditions, so verifiers can confirm exactly what was checked. Short expiration ensures the credential reflects recent chain state.

Why this is different

Traditional auth answers: “Is this user who they claim to be?” Wallet Auth answers: “Does this wallet meet these conditions?” The distinction matters because it enables access control patterns that identity-based auth cannot express.

        - **Gate a premium API tier on stablecoin holdings.** Any wallet holding 10,000+ USDC on any of 7 chains gets access. No signup. No KYC. The JWT proves the holding.
        - **Restrict a governance endpoint to token holders.** Only wallets holding UNI, AAVE, or ARB can call the endpoint. The JWT encodes which conditions were met.
        - **Require a Coinbase Verified Account attestation.** The JWT proves a wallet has an EAS attestation on Base from Coinbase. No raw credential data exposed.
        - **Gate content on NFT ownership.** A BAYC holder gets a JWT that any downstream service can verify. The NFT contract is in the claims. The balance is not.

In each case, the access decision is based on verifiable on-chain state. The JWT is the proof. The verifier needs no blockchain connection.

The portable credential

A Wallet Auth JWT can be forwarded from Service A to Service B and verified without re-querying the chain. Service B fetches the JWKS public key once, checks the ES256 signature, reads the claims, and makes an access decision. No blockchain infrastructure. No Insumer SDK. No network call to InsumerAPI at request time.

The JWKS endpoint is at https://insumermodel.com/.well-known/jwks.json (also available at GET /v1/jwks). The key ID is insumer-attest-v1. Any standard JWT library can verify the token: jose, jsonwebtoken, PyJWT, go-jose, or any JOSE-compliant implementation.

This means a single attestation call produces a credential that can be verified by an unlimited number of downstream services. The JWT is self-contained proof of wallet state at a specific block height, valid for 30 minutes.

Gateway compatible

Wallet Auth JWTs are standard ES256 JWTs backed by a JWKS endpoint. They work with any system that accepts JWKS-backed JWTs:

        - Kong
        - Nginx (ngx_http_auth_jwt module)
        - Cloudflare Access
        - AWS API Gateway
        - Azure API Management
        - Traefik
        - Envoy
        - Any OAuth 2.0 middleware

Point your gateway’s JWKS URL at https://insumermodel.com/.well-known/jwks.json. Configure it to require alg: ES256 and validate iss: https://api.insumermodel.com. That is the entire blockchain integration.

Verification with insumer-verify

The insumer-verify npm package (v1.3.0) auto-detects the input format. Pass either the full response object or the JWT string directly:

import { verifyAttestation } from "insumer-verify";

// response = await res.json() — the full API envelope
// Pass the JWT string directly from response.data.jwt
const result = await verifyAttestation(response.data.jwt, {
  jwksUrl: "https://insumermodel.com/.well-known/jwks.json"
});

console.log(result.valid);   // true
console.log(result.payload); // the verified attestation data

The package runs signature verification, expiry checks, and condition hash validation. Zero dependencies. Works in Node.js 18+ and browsers.

What it costs

Nothing extra. A Wallet Auth attestation costs the same 1 credit ($0.04) as a standard attestation. The format: "jwt" parameter adds the JWT to the response alongside the existing sig field. Both are always available. Merkle proof attestations with JWT cost 2 credits ($0.08), same as without.

Where to start

Get a free API key at insumermodel.com/developers. Add "format": "jwt" to any attestation request. Read the full Wallet Auth documentation for code examples in JavaScript, Python, and curl.

Wallet Auth is available in all four InsumerAPI SDKs: MCP server (v1.7.5), LangChain (v0.9.13), ElizaOS (v1.0.4), and the OpenAI GPT.

A note on error handling: If the API cannot reach one or more upstream data sources after retries, the attestation request returns HTTP 503 with error.code: "rpc_failure". No JWT is issued, no signature is returned, and no credits are charged. This is a retryable error — wait 2–5 seconds and retry. Do not treat rpc_failure as pass: false; it means the API refused to sign an unverified result.

    Share this article

        [

            Share on X
        ](#)
        [

            LinkedIn
        ](#)
        [

            Reddit
        ](#)


            Discord



            Copy Link

InsumerAPI is free to start. Get an API key and try it. View API Docs

Local LLM Unleashed: Faster Inference, Instant Starts, & Open TTS

Local LLM Unleashed: Faster Inference, Instant Starts, & Open TTS

Today’s Highlights

This week, we’re diving into breakthroughs that will redefine your local LLM experience, from dramatically faster inference and sub-second cold starts to a new SOTA open-weight text-to-speech model. Prepare to optimize your RTX GPUs and self-hosted infrastructure with tools and techniques you can implement today.

Mistral AI Releases Voxtral TTS: Open-Weight, SOTA Text-to-Speech (r/LocalLLaMA)

Source: https://reddit.com/r/LocalLLaMA/comments/1s46ylj/mistral_ai_to_release_voxtral_tts_a/

Mistral AI has released Voxtral TTS, a 3-billion-parameter text-to-speech model with open weights that is already making waves in the local LLM community. This model claims to outperform ElevenLabs Flash v2.5 in human preference tests, setting a new bar for high-quality, accessible TTS. Crucially for our readers, Voxtral is designed to run efficiently on consumer hardware, requiring approximately 3 GB of RAM, making it highly suitable for RTX GPUs and self-hosted inference setups. It boasts an impressive 90-millisecond time-to-first-audio (TTFA), ensuring near real-time responsiveness for interactive applications.

Supporting nine languages, Voxtral opens up significant opportunities for developers to integrate advanced, multilingual voice capabilities into their projects without relying on expensive cloud APIs. The availability of open weights means direct access for fine-tuning, experimentation, and local deployment, fostering innovation in areas like conversational AI, voice assistants, and accessibility tools. This release provides a powerful, free alternative for developers who prioritize data privacy, low latency, and full control over their AI stack.

Comment: Finally, a truly competitive open-source TTS model that runs locally on my RTX 4090 with low VRAM. This is a game-changer for building fully self-contained conversational agents, leaving ElevenLabs in the dust for anything where privacy and local control are paramount. I can’t wait to pipe this directly into my agent’s voice output.

RotorQuant: Boosting Inference Speed 10-19x with Clifford Algebra Quantization (r/LocalLLaMA)

Source: https://reddit.com/r/LocalLLaMA/comments/1s44p77/rotorquant_1019x_faster_alternative_to_turboquant/

A groundbreaking new quantization method called RotorQuant has emerged, promising a 10-19x speedup over the existing TurboQuant technique while using 44x fewer parameters. This innovative approach reimagines vector quantization using Clifford Algebra, a powerful mathematical framework, and has been implemented on both CUDA and Metal Shaders. For hands-on developers pushing the limits of local LLM inference on RTX GPUs, this represents a significant leap in efficiency. Faster inference directly translates to higher throughput, lower latency, and the ability to run larger, more complex models on constrained hardware.

The project, available on GitHub (github.com/tonbistudio/turboquant-p), provides direct access to the implementation, allowing developers to immediately integrate and benchmark RotorQuant within their own inference pipelines. The core idea revolves around using Clifford rotors for vector quantization, a novel application that achieves extreme compression without sacrificing performance, potentially even improving it due to reduced memory access and computation. This technical breakthrough offers a clear path to democratizing access to powerful LLMs by making them even more performant and accessible on consumer-grade hardware.

Comment: A 10-19x speedup over TurboQuant? That’s insane for a quantization method. This is exactly the kind of bleeding-edge optimization that makes local inference viable for larger models on my RTX setup. I’m cloning this repo immediately to see how it performs with vLLM and llama.cpp.

Sub-Second Cold Starts: Restoring GPU State for Blazing Fast LLM Inference (r/CUDA)

Source: https://reddit.com/r/CUDA/comments/1s2k5lb/subsecond_cold_start_for_a_32b_model_by_restoring/

A critical pain point in “serverless inference” and on-demand local deployments has been the slow cold start times, often dominated by reloading weights into GPU memory, CUDA context initialization, and KV cache allocation. New experimentation detailed on r/CUDA describes a revolutionary approach to achieve sub-second cold starts for large models (e.g., a 32B parameter model) by restoring the GPU’s state instead of fully reloading weights. This method significantly bypasses the most time-consuming steps, making ephemeral inference endpoints far more responsive and cost-effective.

The technique focuses on snapshotting and restoring the GPU’s memory and execution context, essentially “hibernating” the model state on the GPU. This eliminates the need to transfer gigabytes of weights from host memory to VRAM on every invocation, dramatically cutting down latency for the first request. For developers building self-hosted inference APIs, edge AI applications, or serverless functions that need instant responsiveness, this is a monumental optimization. It enables efficient scaling down to zero without the traditional cold start penalty, unlocking new possibilities for highly dynamic and resource-efficient LLM deployments.

Comment: This is huge for anyone running an API endpoint for local LLMs. The cold start latency for larger models is a real killer, especially with vLLM if you’re trying to scale to zero. Being able to snapshot GPU state instead of reloading weights means my Cloudflare Tunnel endpoints can be truly ‘serverless’ without the annoying first-request delay.

Local LLM Acceleration: Quantization, TTS, and 1M Tokens/Sec

Local LLM Acceleration: Quantization, TTS, and 1M Tokens/Sec

Today’s Highlights

Today’s highlights cover groundbreaking advancements for local LLM builders, from open-source text-to-speech surpassing commercial leaders to extreme quantization techniques that promise up to 19x speedups, and real-world benchmarks pushing inference to a million tokens per second on powerful hardware.

Mistral AI Releases Voxtral TTS with Open Weights, Outperforming ElevenLabs (r/LocalLLaMA)

Source: https://reddit.com/r/LocalLLaMA/comments/1s46ylj/mistral_ai_to_release_voxtral_tts_a/

Mistral AI has just announced the release of Voxtral TTS, a 3-billion-parameter text-to-speech model with fully open weights. This is a massive win for local AI enthusiasts and developers looking to integrate high-quality speech synthesis into their self-hosted applications.

Voxtral TTS boasts impressive performance, with Mistral AI claiming it outperforms ElevenLabs Flash v2.5 in human preference tests. Technically, it’s designed for efficiency, running on approximately 3 GB of RAM, making it highly accessible for systems with even modest GPUs. Furthermore, its ultra-low latency of 90 milliseconds time-to-first-audio ensures a highly responsive and natural conversational experience. The model supports nine languages, significantly broadening its utility for global applications. The immediate availability of its weights on Hugging Face (linked in the news item) means developers can download, integrate, and experiment with Voxtral TTS today, bypassing proprietary APIs and associated costs. This release marks a significant step towards democratizing advanced speech technology, empowering builders to create sophisticated conversational agents, audiobooks, or assistive technologies directly on their local infrastructure.

For developers, the open weights are key. You can git clone the repository, pip install the necessary dependencies, and start generating speech with a few lines of Python. Its small footprint and high performance make it an ideal candidate for edge AI deployments or local inference on consumer-grade hardware, providing a robust, high-fidelity alternative to cloud-based services.

Comment: This is a game-changer for building fully local, low-latency conversational AI. Running Voxtral on my RTX 5090 for STT/TTS with a local LLM means I can finally build truly private, real-time voice assistants without cloud dependencies or worrying about API costs.

RotorQuant: 10-19x Faster Quantization for Local LLMs via Clifford Rotors (r/LocalLLaMA)

Source: https://reddit.com/r/LocalLLaMA/comments/1s44p77/rotorquant_1019x_faster_alternative_to_turboquant/

A new research project, RotorQuant, is making waves by proposing an alternative to TurboQuant that promises unprecedented efficiency gains for AI models. RotorQuant claims to be 10-19 times faster than TurboQuant while using a staggering 44 times fewer parameters. This advancement is achieved through the application of Clifford Algebra Vector Quantization, a novel approach to model compression.

For hands-on developers struggling with VRAM limitations and inference speeds on local hardware, RotorQuant presents a potentially revolutionary solution. By drastically reducing parameter count and accelerating quantization, it opens the door to running larger, more complex LLMs on consumer-grade GPUs, or significantly boosting the throughput of existing models. The project mentions implementation on CUDA and Metal shaders, indicating direct applicability for NVIDIA RTX users and Apple Silicon developers alike. The GitHub repository, linked in the original post, provides the necessary code to explore this technology firsthand.

The implications for local LLM inference are profound. Faster quantization means quicker model loading, reduced memory footprint, and ultimately, higher tokens-per-second generation. This could empower developers to experiment with models previously deemed too large for their setups, or to serve multiple smaller models concurrently without bottlenecking system resources. It represents a significant step towards making cutting-edge AI truly accessible and performant on self-hosted infrastructure.

Comment: 10-19x faster quantization and 44x fewer params? This is exactly what my RTX 5090 needs to push beyond its limits. I’m hitting git clone immediately to see if I can run 100B+ models locally with this wizardry.

Benchmarking Qwen 3.5 27B at 1 Million Tokens/Second with vLLM on B200 GPUs (r/MachineLearning)

Source: https://reddit.com/r/MachineLearning/comments/1s4hxgu/d_1m_tokenssecond_serving_qwen_35_27b_on_b200/

A detailed report showcases the process and findings of serving Qwen 3.5 27B, a dense FP8 model, at an astounding 1.1 million total tokens per second. This benchmark was achieved on a cluster of 96 NVIDIA B200 GPUs using vLLM v0.18.0. While the hardware might be out of reach for most individual developers, the technical insights into maximizing LLM serving throughput are invaluable.

The key takeaway from this experiment revolves around distributed inference strategies. The authors found that Data Parallelism (DP=8) achieved nearly a 4x improvement in throughput over Tensor Parallelism (TP=8) for the Qwen 3.5 27B model. This counter-intuitive result is attributed to the model being ‘too small’ for tensor parallelism to be optimally efficient across 8 devices, making data parallelism the superior strategy for maximizing throughput in this specific configuration. This highlights a critical, often overlooked nuance in scaling LLM inference: the optimal parallelism strategy is highly dependent on the model’s size relative to the available hardware and network topology.

For developers building and optimizing their own local or self-hosted LLM serving infrastructure, these findings are golden. Understanding the interplay between model size, parallelism techniques, and framework choices like vLLM is crucial for extracting peak performance from your RTX GPUs. It encourages a deeper dive into profiling and experimentation to determine whether DP or TP, or a hybrid approach, will yield the best results for your specific model and hardware setup, rather than blindly applying one strategy over another. This isn’t just about raw speed on enterprise hardware; it’s about the architectural decisions that enable that speed.

Comment: This vLLM benchmark provides critical insights into optimizing distributed inference. The finding that DP=8 outpaced TP=8 for Qwen 3.5 27B is huge for anyone running an RTX cluster – it means I need to re-evaluate my own parallelism strategies to squeeze every last token out of my setup.

Headless Browser Detection: How Sites Know You’re a Bot

The Detection Arms Race

You launch your Puppeteer script, it works perfectly in testing, then fails in production. The site knows you are a bot. But how?

Modern bot detection goes far beyond checking user agents. Let’s dive into exactly how sites detect headless browsers and how to defend against each technique.

Detection Method 1: The WebDriver Flag

The simplest check. Every automated browser sets navigator.webdriver = true:

// What sites check
if (navigator.webdriver) {
    // Block this visitor
}

Defense in Python with Playwright:

from playwright.sync_api import sync_playwright

def create_stealth_browser():
    p = sync_playwright().start()
    browser = p.chromium.launch(
        headless=True,
        args=["--disable-blink-features=AutomationControlled"]
    )
    context = browser.new_context()

    # Remove webdriver flag
    context.add_init_script("""
        Object.defineProperty(navigator, 'webdriver', {
            get: () => undefined
        });
    """)

    return browser, context

Detection Method 2: Chrome DevTools Protocol

Sites detect if CDP (Chrome DevTools Protocol) is active:

// Check for CDP artifacts
if (window.cdc_adoQpoasnfa76pfcZLmcfl_Array ||
    window.cdc_adoQpoasnfa76pfcZLmcfl_Promise) {
    // Automated browser detected
}

This is why undetected-chromedriver exists — it patches these artifacts:

import undetected_chromedriver as uc

driver = uc.Chrome(headless=True)
driver.get("https://nowsecure.nl")  # Bot detection test site

Detection Method 3: Browser Fingerprinting

Sites build a fingerprint from dozens of browser properties:

// Canvas fingerprinting
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillText('Hello', 2, 2);
const fingerprint = canvas.toDataURL();
// Headless browsers produce different canvas renders

// WebGL fingerprinting
const gl = canvas.getContext('webgl');
const renderer = gl.getParameter(gl.RENDERER);
// Headless Chrome: "Google SwiftShader"
// Real Chrome: "ANGLE (Intel, ...)"

Defense:

# Inject realistic WebGL values
context.add_init_script("""
    const getParameter = WebGLRenderingContext.prototype.getParameter;
    WebGLRenderingContext.prototype.getParameter = function(parameter) {
        if (parameter === 37445) return 'Intel Inc.';
        if (parameter === 37446) return 'Intel Iris OpenGL Engine';
        return getParameter.call(this, parameter);
    };
""")

Detection Method 4: Missing Browser APIs

Headless browsers lack certain APIs that real browsers have:

// Notification API check
if (!window.Notification) {
    // Probably headless
}

// Permission check
navigator.permissions.query({name: 'notifications'}).then(perm => {
    if (perm.state === 'prompt') {
        // Real browser behavior
    }
});

// Plugin check
if (navigator.plugins.length === 0) {
    // Headless browsers have no plugins
}

Defense:

context.add_init_script("""
    // Fake plugins
    Object.defineProperty(navigator, 'plugins', {
        get: () => [1, 2, 3, 4, 5]  // Non-empty
    });

    Object.defineProperty(navigator, 'languages', {
        get: () => ['en-US', 'en']
    });
""")

Detection Method 5: Behavioral Analysis

Advanced detection tracks how users interact:

// Mouse movement patterns
let movements = [];
document.addEventListener('mousemove', (e) => {
    movements.push({x: e.clientX, y: e.clientY, t: Date.now()});
});

// Bots move in straight lines or not at all
// Humans have natural curves and micro-movements

// Timing analysis
const loadTime = performance.timing.domContentLoadedEventEnd -
                 performance.timing.navigationStart;
// Bots often load pages unnaturally fast

Defense:

import random
import asyncio

async def human_like_interaction(page):
    # Random mouse movements
    for _ in range(random.randint(3, 7)):
        x = random.randint(100, 800)
        y = random.randint(100, 600)
        await page.mouse.move(x, y, steps=random.randint(10, 25))
        await asyncio.sleep(random.uniform(0.1, 0.5))

    # Scroll naturally
    for _ in range(random.randint(2, 5)):
        delta = random.randint(100, 400)
        await page.mouse.wheel(0, delta)
        await asyncio.sleep(random.uniform(0.5, 1.5))

Detection Method 6: TLS Fingerprinting

Your HTTP library’s TLS handshake is unique. Python requests has a fingerprint that screams “bot”:

# Standard requests - detectable TLS fingerprint
import requests
resp = requests.get(url)  # JA3 hash identifies this as Python

# curl_cffi - mimics Chrome's TLS fingerprint
from curl_cffi import requests as cf_requests
resp = cf_requests.get(url, impersonate="chrome120")  # Matches real Chrome

Detection Method 7: IP Reputation

Datacenter IPs are in public databases. Sites check instantly:

# Datacenter IP = instant block
# Residential IP = trusted

# Use residential proxies from ThorData
proxies = {"https": "http://user:[email protected]:9000"}
resp = requests.get(url, proxies=proxies)

ThorData provides residential proxies that pass IP reputation checks.

The Complete Stealth Stack

For maximum evasion, combine all defenses:

from playwright.async_api import async_playwright
import random
import asyncio

async def stealth_scrape(url):
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=[
                "--disable-blink-features=AutomationControlled",
                "--no-sandbox",
                "--disable-dev-shm-usage",
            ]
        )

        context = await browser.new_context(
            viewport={"width": 1920, "height": 1080},
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
            locale="en-US",
            timezone_id="America/New_York",
            color_scheme="light",
        )

        # Apply all stealth patches
        await context.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
            Object.defineProperty(navigator, 'plugins', {get: () => [1,2,3]});
            Object.defineProperty(navigator, 'languages', {get: () => ['en-US','en']});

            const origGetParameter = WebGLRenderingContext.prototype.getParameter;
            WebGLRenderingContext.prototype.getParameter = function(p) {
                if (p === 37445) return 'Intel Inc.';
                if (p === 37446) return 'Intel Iris OpenGL Engine';
                return origGetParameter.call(this, p);
            };
        """)

        page = await context.new_page()
        await page.goto(url, wait_until="networkidle")

        # Simulate human behavior
        await human_like_interaction(page)

        content = await page.content()
        await browser.close()
        return content

Or Just Use an API

All of the above is a lot of work to maintain. ScraperAPI handles all detection bypass automatically — they keep up with the arms race so you do not have to.

Monitor your detection rates with ScrapeOps to know when your evasion techniques stop working.

Conclusion

Bot detection is a multi-layered system. No single technique catches every bot, and no single evasion defeats every detector. The key is understanding what each layer checks and ensuring your scraper passes all of them. For production use, a managed proxy service is almost always more cost-effective than maintaining your own stealth infrastructure.

I built a time & money tracker that stores everything in your browser

Every time tracker I tried wanted me to create an account, store my data on their servers, and pay a monthly subscription for basic features.

I just wanted to log my hours and see where my money goes. Privately. Without another SaaS login.

So I built DayTrak — a time and money tracker that runs entirely in your browser. No accounts, no cloud, no subscriptions. Your data never leaves your device.

What it does

DayTrak combines time tracking and personal finance in one workspace:

Time Tracking

  • Track hours across multiple projects with a built-in timer
  • Set monthly goals and see pacing indicators (ahead/behind schedule)
  • Hourly, monthly salary, or hybrid compensation models

Income & Expenses

  • Recurring and one-time income/expenses with categories
  • Spending trends, category breakdowns, daily averages
  • Paid invoices automatically flow into income

Invoicing

  • Create invoices from tracked hours
  • Select which entries to include
  • Track status: Draft → Sent → Paid

Financial Dashboard

  • Income vs Expenses bar chart (6-month sliding window)
  • Net balance and savings at a glance
  • Expense category donut chart

Pivot Table

  • 4 data modes: Time Tracking, Income, Expenses, and All (combined)
  • Month scope (daily columns) or Year scope (monthly columns)
  • Context-aware Month/Year toggle

Why local-first?

This is the part that developers usually ask about, so let me explain the architecture.

DayTrak uses Evolu — a local-first framework that runs SQLite directly in the browser via WebAssembly. Your data lives in IndexedDB, queries run locally, and everything works offline.

How it works

Browser
  └── Evolu
       ├── SQLite (via WASM) → stores all data in IndexedDB
       ├── Kysely query builder → type-safe queries
       └── CRDTs → conflict-free sync between devices

There’s no server. No database to manage. No API to build. The entire backend is the browser itself.

Why this matters

  1. Privacy by architecture — I can’t see your data even if I wanted to. There’s no server to hack, no database to leak. Your financial data stays on your device.

  2. Works offline — Open the app on a plane, in a basement, wherever. No internet needed after the first load. It’s a PWA — install it and it works like a native app.

  3. Zero infrastructure cost — I serve static files from a CDN. That’s it. No servers to maintain, no database backups to manage, no scaling to worry about. This means I can offer it for free forever.

  4. Instant performance — Queries run against a local SQLite database, not over a network. Everything is fast because there’s no round-trip to a server.

Multi-device sync (optional)

Evolu includes end-to-end encrypted sync via CRDTs. You get a 24-word recovery phrase — enter it on another device and your data syncs automatically. The relay server only sees encrypted blobs. It can’t read your data.

The trade-off

Local-first means if you clear your browser data without a backup, it’s gone. That’s the honest trade-off. DayTrak mitigates this with:

  • JSON backup & restore (export everything with one click)
  • Recovery phrase for multi-device sync
  • PWA installation (more persistent than a regular tab)

Tech stack

Layer Technology
Framework Vue 3 (Composition API)
Language TypeScript
Build Vite
Styling Tailwind CSS v4
Database Evolu (SQLite + CRDTs)
i18n vue-i18n (English + Slovak)
Icons lucide-vue-next
PWA vite-plugin-pwa

The entire app is about 450KB gzipped. No heavy dependencies, no bloat.

What I learned building it

1. Local-first simplifies everything

No auth system, no API endpoints, no database migrations, no server monitoring. I just write Vue components that read/write to a local database. The development experience is remarkably simple.

2. CRDTs are magic (and sometimes confusing)

Evolu handles conflict resolution automatically. If you edit the same record on two devices, CRDTs merge the changes without conflicts. But “soft deletes” (marking records as deleted rather than removing them) took some getting used to.

3. Financial data needs careful handling

All monetary values are stored as cents (integer strings) to avoid floating-point issues. Dates use local timezone (not UTC) because 00:30 in your timezone is still “today,” not “yesterday.” These small details matter a lot for a finance app.

4. The pivot table was the hardest feature

Supporting 4 data modes (time tracking, income, expenses, combined) with 2 time scopes (month/year), sticky columns, and responsive scrolling — all in one component — was by far the most complex feature. The component is about 1,300 lines of Vue.

What’s next

I’m launching DayTrak with all features free. I want real feedback before deciding on pricing.

Some things I’m considering:

  • Auto-backup to cloud folders (Google Drive, Dropbox, iCloud via File System Access API)
  • AI-powered insights (bring your own API key — data stays private)
  • Bank integration for automatic expense import
  • Monthly/yearly financial reports

Try it

daytrak.app — no sign-up, works in your browser, free.

I’d love to hear what you think. What features would make this useful for you?

Follow the journey: @day_trak_app