DocsRecipes

Recipes

JVM and .NET reference implementations

3 min readReviewed July 2026

GraphJSON uses ordinary JSON over HTTP, so Java, Kotlin, and .NET applications do not need a GraphJSON SDK. A production integration still needs a typed event contract, server-only configuration, bounded network behavior, durable delivery for important facts, and tested serialization.

The examples below use the public logging contract:

POST https://api.graphjson.com/api/log
POST https://api.graphjson.com/api/bulk-log

The json field is itself a serialized JSON object, and timestamps are whole Unix seconds.

Share one event envelope#

Use a portable internal type:

event ID
occurrence time
destination collection
schema version
typed payload

The event ID belongs inside the analytical payload for reconciliation and deduplication. GraphJSON ingestion does not enforce event-ID uniqueness.

Never expose the workspace API key to browser, mobile, desktop, or distributed client code.

Java record and transport#

Java 17 example:

package com.example.analytics;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;

public final class GraphJsonClient {
  private static final URI LOG_URI =
      URI.create("https://api.graphjson.com/api/log");

  private final String apiKey;
  private final HttpClient http;
  private final ObjectMapper json;

  public GraphJsonClient(String apiKey, ObjectMapper json) {
    if (apiKey == null || apiKey.isBlank()) {
      throw new IllegalArgumentException("GRAPHJSON_API_KEY is required");
    }
    this.apiKey = apiKey;
    this.json = json;
    this.http = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(3))
        .build();
  }

  public record Event(
      String collection,
      Instant occurredAt,
      Map<String, Object> payload
  ) {}

  private record LogRequest(
      String api_key,
      String collection,
      long timestamp,
      String json
  ) {}

  public void send(Event event) throws Exception {
    String payload = json.writeValueAsString(event.payload());
    if (payload.length() > 10_000) {
      throw new IllegalArgumentException("event exceeds 10,000 characters");
    }

    String body = json.writeValueAsString(new LogRequest(
        apiKey,
        event.collection(),
        event.occurredAt().getEpochSecond(),
        payload
    ));

    HttpRequest request = HttpRequest.newBuilder(LOG_URI)
        .timeout(Duration.ofSeconds(5))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

    HttpResponse<String> response =
        http.send(request, HttpResponse.BodyHandlers.ofString());

    if (response.statusCode() < 200 || response.statusCode() >= 300) {
      throw new GraphJsonException(response.statusCode());
    }
  }
}

Do not put response bodies in ordinary logs; they can contain validation details derived from customer fields. Classify by status and retain only bounded diagnostics.

Kotlin typed event#

Kotlin applications can reuse the Java transport and expose a typed event:

data class WorkflowCompleted(
    val event: String = "workflow_completed",
    val event_id: String,
    val account_id: String,
    val workflow: String,
    val result: String,
    val schema_version: Int = 2
)

fun trackWorkflowCompleted(
    graphJSON: GraphJsonClient,
    value: WorkflowCompleted,
    occurredAt: Instant
) {
    graphJSON.send(
        GraphJsonClient.Event(
            "product_events",
            occurredAt,
            mapOf(
                "event" to value.event,
                "event_id" to value.event_id,
                "account_id" to value.account_id,
                "workflow" to value.workflow,
                "result" to value.result,
                "schema_version" to value.schema_version
            )
        )
    )
}

Validate bounded enums, required IDs, integer money, and property lengths before enqueueing.

Spring Boot configuration#

Keep the key in server configuration:

@ConfigurationProperties(prefix = "graphjson")
public record GraphJsonProperties(
    String apiKey,
    String collection
) {}
graphjson:
  api-key: ${GRAPHJSON_API_KEY}
  collection: product_events

Do not include the value in actuator output, configuration dumps, exception messages, or startup logs.

Avoid sending important events in an HTTP request interceptor after the response has already been committed. Write an outbox record in the business transaction.

Java transactional outbox#

database transaction
├── apply business state
└── insert analytics_outbox row

committed rows
  → worker claims bounded batch
  → POST /api/bulk-log
  → mark delivered after accepted response

Suggested row:

id
collection
occurred_at
event_id
payload_json
schema_version
attempt_count
next_attempt_at
claimed_until
delivered_at

Use SELECT ... FOR UPDATE SKIP LOCKED or an equivalent lease to prevent two workers from claiming the same row. Reclaim expired leases and keep retries bounded.

.NET event and client#

.NET 8 example:

using System.Net.Http.Json;
using System.Text.Json;

public sealed record AnalyticsEvent(
    string Collection,
    DateTimeOffset OccurredAt,
    object Payload
);

public sealed class GraphJsonClient
{
    private readonly HttpClient _http;
    private readonly string _apiKey;
    private readonly JsonSerializerOptions _json;

    public GraphJsonClient(
        HttpClient http,
        IConfiguration configuration,
        JsonSerializerOptions json)
    {
        _http = http;
        _apiKey = configuration["GRAPHJSON_API_KEY"]
            ?? throw new InvalidOperationException(
                "GRAPHJSON_API_KEY is required");
        _json = json;
    }

    public async Task SendAsync(
        AnalyticsEvent value,
        CancellationToken cancellationToken)
    {
        var serializedEvent =
            JsonSerializer.Serialize(value.Payload, _json);

        if (serializedEvent.Length > 10_000)
            throw new ArgumentException(
                "Event exceeds 10,000 characters");

        var request = new
        {
            api_key = _apiKey,
            collection = value.Collection,
            timestamp = value.OccurredAt.ToUnixTimeSeconds(),
            json = serializedEvent
        };

        using var response = await _http.PostAsJsonAsync(
            "api/log",
            request,
            _json,
            cancellationToken);

        if (!response.IsSuccessStatusCode)
            throw new GraphJsonException((int)response.StatusCode);
    }
}

Register one typed client:

services.AddHttpClient<GraphJsonClient>(client =>
{
    client.BaseAddress =
        new Uri("https://api.graphjson.com/");
    client.Timeout = TimeSpan.FromSeconds(5);
});

Do not construct a new HttpClient per event.

ASP.NET collection endpoint#

Browser events should flow through an authenticated, rate-limited first-party route:

app.MapPost("/analytics/events", async (
    BrowserEvent input,
    ClaimsPrincipal user,
    GraphJsonOutbox outbox,
    CancellationToken cancellationToken) =>
{
    var accountId = AuthorizeAndResolveAccount(user, input.AccountId);
    var normalized = NormalizeAllowlistedEvent(input, accountId);
    await outbox.EnqueueAsync(normalized, cancellationToken);
    return Results.Accepted();
})
.RequireAuthorization()
.RequireRateLimiting("analytics");

The route must ignore browser claims about roles, plan, price, entitlement, success, or account membership. Derive trusted fields on the server.

Send bulk batches#

Both runtimes should serialize:

{
  "api_key": "server_secret",
  "collection": "product_events",
  "timestamps": [1785081600, 1785081612],
  "jsons": [
    "{\"event\":\"order_completed\",\"event_id\":\"evt_1\"}",
    "{\"event\":\"order_completed\",\"event_id\":\"evt_2\"}"
  ]
}

Requirements:

  • at most 50 events
  • one destination collection
  • parallel timestamp and JSON arrays
  • at most 10,000 serialized characters per event
  • whole Unix-second timestamps

Group outbox rows by collection before batching. A partial source selection must not be marked delivered until its GraphJSON batch is accepted.

Classify retries#

Result Worker behavior
Timeout or temporary network error Retry with backoff and jitter
429 Slow down and retry within the delivery budget
5xx Retry; alert on sustained failure
Invalid request Quarantine after validation review
Unauthorized Stop and rotate or repair configuration
Oversized event Quarantine; do not retry unchanged

Do not retry forever. Preserve the original occurrence time and stable event ID on every attempt.

Test serialization and transport#

Unit tests should verify:

  • the json field is a JSON string, not an object
  • timestamps are Unix seconds
  • required IDs and enum values reject invalid input
  • API key never appears in logs or exceptions
  • payload-size guard runs before the network call
  • cancellation interrupts the request
  • non-success responses classify correctly
  • bulk arrays remain aligned

Use a fake HTTP handler or local test server. Keep one staging smoke test against a dedicated collection, then query by a unique test-run ID.

Production checklist#

  • The API key exists only in server configuration.
  • Events use typed, versioned payloads and stable IDs.
  • Event time uses whole Unix seconds.
  • One long-lived bounded HTTP client is reused.
  • Important facts enter an outbox in the business transaction.
  • Bulk batches contain at most 50 events in one collection.
  • Retry behavior distinguishes temporary and permanent failures.
  • Original timestamp and event ID survive every retry.
  • Browser routes authenticate, authorize, allowlist, and rate limit.
  • Tests verify nested serialization and secret safety.

For additional delivery architecture, continue with Reliable event delivery.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support