PDF Security Blog

PDF Tamper Detection API for C#: ASP.NET Core Integration Guide

HTPBE Team··23 min read
PDF Tamper Detection API for C#: ASP.NET Core Integration Guide

This article is a snapshot — content was accurate as of July 2026 (code examples tested against the API as of June 2026). The product evolves actively; specific counts, examples, and detection rules may have changed since publication — see the changelog for the current state.

PDF fraud is a backend problem, and in enterprise fintech a great deal of that backend runs on .NET. An ASP.NET Core service ingests an uploaded bank statement, a payslip, or a claim packet, writes a row, and hands the document to underwriting — and by the time your controller has returned 201 Created, the document’s claims have already propagated into your business logic. Your KYC provider confirmed the applicant is a real person with a valid identity. It said nothing about whether the PDF they uploaded was edited after the bank generated it. That structural-tampering layer is invisible to identity verification, and the right place to catch it is at ingress: before your service trusts the file, not after.

This guide walks through integrating the PDF tamper detection API into an ASP.NET Core application — from the first curl command to an idiomatic typed HtpbeClient built on IHttpClientFactory, with System.Text.Json record DTOs, configuration-bound options for the API key, polling with backoff, error handling that distinguishes a configuration failure from a transient one, and a small bank-statement gate that decides accept / reject / review. The patterns target .NET 8 (the current LTS) and use minimal APIs, but everything maps cleanly to a controller-based project. Treat the code as a reference architecture — it runs the real request flow against the documented error codes, but you should adapt and harden it for your own traffic profile and threat model. (If you want the conceptual overview first, start with How to Detect PDF Tampering Programmatically. Integrating from another stack? See the Java / Spring Boot, Go, Node.js, Python, and Laravel / PHP guides.)

TL;DR

  • Two API calls, three verdicts: POST /analyze returns a top-level id, then GET /result/{id} returns the flat verdict object whose status is one of intact, modified, or inconclusive.
  • The minimum integration is a typed HttpClient and two awaited calls. No dependency beyond the framework and System.Text.Json.
  • Production shape: an HtpbeClient typed client registered with IHttpClientFactory, record DTOs with snake_case naming, a custom exception carrying the status code, and a Polly retry policy that backs off on 5xx and 429 only.
  • A DocumentGate that maps the three verdicts to an Accept / Reject / Review decision for documents that claim institutional origin.
  • This is structural PDF tamper and forgery detection — not KYC, not OCR, not AI-text detection. It complements an identity stack; it does not replace one.

Prerequisites

  • .NET 8 SDK (records, required members, IHttpClientFactory, minimal APIs)
  • An HTPBE? API key (Dashboard → copy key)
  • Optionally Microsoft.Extensions.Http.Polly for the resilience policy in Step 6 (the framework HttpClient works without it)

Step 1: Test the API with curl

Before writing any C#, confirm your key works. The API uses a two-step flow: POST /analyze submits a PDF URL and returns a check id, then GET /result/{id} retrieves the full verdict. (For a language-agnostic overview of what the API detects, see how PDF tamper detection works.)

Step 1a — submit for analysis:

curl -X POST https://api.htpbe.tech/v1/analyze \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.htpbe.tech/v1/test/clean.pdf"}'

You will receive a flat object with a single field: {"id": "00000000-0000-4000-8000-000000000001"}

Step 1b — retrieve the result:

curl https://api.htpbe.tech/v1/result/YOUR_CHECK_ID \
  -H "Authorization: Bearer YOUR_API_KEY"

You will receive a flat JSON object with "status": "intact" and the full set of analysis fields:

{
  "id": "00000000-0000-4000-8000-000000000001",
  "filename": "clean.pdf",
  "status": "intact",
  "origin": { "type": "institutional", "software": null },
  "creator": "Adobe Acrobat Pro DC",
  "producer": "Adobe PDF Library 15.0",
  "modification_confidence": "none",
  "has_incremental_updates": false,
  "update_chain_length": 1,
  "signature_removed": false,
  "modifications_after_signature": false,
  "modification_markers": []
}

The same shape comes back for modified and inconclusive verdicts — only the values change. Two fields are conditional: status_reason appears only when status is inconclusive, and outdated_warning only when the check ran against an older algorithm version. Note that there is no data wrapper and no numeric risk score — the response is the flat object above, and the whole signal is the verdict plus the named markers.

The URL https://api.htpbe.tech/v1/test/clean.pdf is a test mock — it returns a predictable response without consuming quota. Test keys (prefix htpbe_test_) accept only these mock URLs; live keys (prefix htpbe_live_) accept any public PDF URL.

Step 2: Bind Configuration, Not Constants

Keep the key and base URL out of code. Bind them to a typed options record so they are validated at startup and overridable per environment. The key resolves from configuration — appsettings.json, an environment variable, or a secrets store — never a hard-coded literal.

namespace Htpbe;

public sealed class HtpbeOptions
{
    public const string SectionName = "Htpbe";

    public required string ApiKey { get; init; }
    public string BaseUrl { get; init; } = "https://api.htpbe.tech/v1/";
    public int TimeoutSeconds { get; init; } = 35;
    public int MaxResultPollAttempts { get; init; } = 5;
}

Provide the values in appsettings.json, leaving the key itself empty so it is supplied by an environment variable or user-secret at runtime:

{
  "Htpbe": {
    "ApiKey": "",
    "BaseUrl": "https://api.htpbe.tech/v1/",
    "TimeoutSeconds": 35,
    "MaxResultPollAttempts": 5
  }
}

In development, store the key with dotnet user-secrets set "Htpbe:ApiKey" "htpbe_live_...". In production, set the environment variable Htpbe__ApiKey (the double underscore maps to the nested section). Either way the key never lands in source control. Bind and validate the options in Program.cs so a missing key fails the application at boot, not on the first document that arrives at 2 a.m.:

builder.Services
    .AddOptions<HtpbeOptions>()
    .Bind(builder.Configuration.GetSection(HtpbeOptions.SectionName))
    .Validate(o => !string.IsNullOrWhiteSpace(o.ApiKey),
        "Htpbe:ApiKey is not configured. Set the Htpbe__ApiKey environment variable.")
    .ValidateOnStart();

Step 3: The Result DTO

Model the GET /result/{id} response as C# records. System.Text.Json maps snake_case JSON to PascalCase members when you set JsonNamingPolicy.SnakeCaseLower on the serializer options (available since .NET 8). Reference types stay nullable so “absent” remains distinguishable from a genuine zero, and unknown fields are ignored by default so a newly added API field never breaks deserialization.

using System.Text.Json.Serialization;

namespace Htpbe;

public sealed record AnalysisResult
{
    public required string Id { get; init; }
    public string? Filename { get; init; }
    public long? FileSize { get; init; }
    public int? PageCount { get; init; }

    public string? AlgorithmVersion { get; init; }
    public string? CurrentAlgorithmVersion { get; init; }
    public string? OutdatedWarning { get; init; } // present only on an outdated check

    // Primary verdict: "intact" | "modified" | "inconclusive"
    public required string Status { get; init; }

    // Present only when Status == "inconclusive":
    // "consumer_software_origin" | "online_editor_origin" |
    // "scanned_document" | "unverifiable_metadata"
    public string? StatusReason { get; init; }

    public Origin? Origin { get; init; }

    // "certain" | "high" | "none" | null
    public string? ModificationConfidence { get; init; }

    public string? Creator { get; init; }
    public string? Producer { get; init; }
    public long? CreationDate { get; init; }     // Unix seconds
    public long? ModificationDate { get; init; } // Unix seconds
    public string? PdfVersion { get; init; }

    public bool DateSequenceValid { get; init; }
    public int MetadataCompletenessScore { get; init; }

    public int XrefCount { get; init; }
    public bool HasIncrementalUpdates { get; init; }
    public int UpdateChainLength { get; init; }

    public bool HasDigitalSignature { get; init; }
    public int SignatureCount { get; init; }
    public bool SignatureRemoved { get; init; }
    public bool ModificationsAfterSignature { get; init; }

    public int ObjectCount { get; init; }
    public bool HasJavascript { get; init; }
    public bool HasEmbeddedFiles { get; init; }

    // Stable HTPBE_* marker ids, e.g. ["HTPBE_SIGNATURE_REMOVED"].
    // Empty when Status is "intact" or "inconclusive".
    public IReadOnlyList<string> ModificationMarkers { get; init; } = [];

    [JsonIgnore] public bool IsIntact => Status == "intact";
    [JsonIgnore] public bool IsModified => Status == "modified";
    [JsonIgnore] public bool IsInconclusive => Status == "inconclusive";
}

public sealed record Origin
{
    // "consumer_software" | "institutional" | "unknown" |
    // "online_editor" | "scanned"
    public string? Type { get; init; }
    public string? Software { get; init; }
}

// The /analyze response carries only the check id.
public sealed record AnalyzeResponse
{
    public required string Id { get; init; }
}

Two fields deserve a closer look. StatusReason is populated only when Status is inconclusive, and it carries one of several values — consumer_software_origin, online_editor_origin, scanned_document, and a few more. The difference matters: a scanned document is benign for a user-submitted handwritten form, but a consumer_software_origin on something that claims to be a payslip is the kind of origin you would not expect from a real payroll system — that class covers both consumer apps and freely available HTML-to-PDF renderers, so it is a strong signal to route for review. Branch on the specific reason, not just on the top-level inconclusive.

ModificationMarkers returns stable, machine-readable ids prefixed HTPBE_ — for example HTPBE_SIGNATURE_REMOVED, HTPBE_DATES_DISAGREE, HTPBE_POST_SIGNATURE_EDIT, HTPBE_MULTIPLE_REVISION_LAYERS. Branch your integration logic on the id; render the human-readable label from the dictionary published on htpbe.tech/how. These ids are part of the public contract and never change once shipped. The API does not return a numeric risk score — the verdict plus the named markers are the whole signal, by design, so there is no threshold to tune on your side.

A small enum keeps the rest of your codebase from comparing against bare string literals:

namespace Htpbe;

public enum Verdict { Intact, Modified, Inconclusive }

public static class VerdictParser
{
    public static Verdict Parse(string status) => status switch
    {
        "intact" => Verdict.Intact,
        "modified" => Verdict.Modified,
        "inconclusive" => Verdict.Inconclusive,
        _ => throw new ArgumentOutOfRangeException(
            nameof(status), status, "unknown status from HTPBE"),
    };
}

Step 4: A Typed Exception

A 401 means your key is wrong; a 402 means the credit pool is dry; a 500 is transient. Both the retry layer and your business logic need to branch on the status code, so wrap every non-success response in a typed exception that carries it.

namespace Htpbe;

public sealed class HtpbeApiException : Exception
{
    public int StatusCode { get; }
    public string Code { get; }                 // machine-readable code from the JSON body
    public int? RetryAfterSeconds { get; }      // parsed from Retry-After on 429; null if absent

    public HtpbeApiException(int statusCode, string code, string message, int? retryAfterSeconds = null)
        : base($"htpbe: {statusCode} {code}: {message}")
    {
        StatusCode = statusCode;
        Code = code;
        RetryAfterSeconds = retryAfterSeconds;
    }

    // Only 5xx and 429 are transient. Every other 4xx is permanent —
    // retrying it burns latency and, for 402, can never succeed until
    // the account is topped up.
    public bool Retryable => StatusCode >= 500 || StatusCode == 429;
}

Step 5: The Typed HttpClient

Here is the complete client. It is a typed HttpClient — registered with IHttpClientFactory in Step 6 — so the factory owns the connection pool and the Authorization header is set once at registration. The client exposes one public method, VerifyAsync, that runs both steps of the flow; a private ParseErrorAsync converts every non-success response into an HtpbeApiException, reading the JSON error body and the Retry-After header in one place.

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;

namespace Htpbe;

public sealed class HtpbeClient
{
    private readonly HttpClient _http;
    private readonly int _maxPollAttempts;

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
        PropertyNameCaseInsensitive = true,
    };

    public HtpbeClient(HttpClient http, IOptions<HtpbeOptions> options)
    {
        _http = http;
        _maxPollAttempts = options.Value.MaxResultPollAttempts;
    }

    /// <summary>
    /// Submits a PDF URL and returns the full verdict. The two steps are kept
    /// separate on purpose: POST /analyze is the billable, job-creating call,
    /// GET /result/{id} is a free read. The resilience policy (Step 6) wraps
    /// the whole client, but only transient failures are retried — a permanent
    /// 402 short-circuits immediately.
    /// </summary>
    public async Task<AnalysisResult> VerifyAsync(
        string pdfUrl, string? originalFilename = null, CancellationToken ct = default)
    {
        var id = await SubmitAnalysisAsync(pdfUrl, originalFilename, ct);
        return await GetResultAsync(id, ct);
    }

    private async Task<string> SubmitAnalysisAsync(
        string pdfUrl, string? originalFilename, CancellationToken ct)
    {
        // The API accepts a JSON body with the PDF URL; original_filename is optional.
        var body = originalFilename is null
            ? new Dictionary<string, string> { ["url"] = pdfUrl }
            : new Dictionary<string, string> { ["url"] = pdfUrl, ["original_filename"] = originalFilename };

        using var response = await _http.PostAsJsonAsync("analyze", body, JsonOptions, ct);

        if (!response.IsSuccessStatusCode)
            throw await ParseErrorAsync(response, ct);

        var payload = await response.Content.ReadFromJsonAsync<AnalyzeResponse>(JsonOptions, ct);
        if (payload is null || string.IsNullOrEmpty(payload.Id))
            throw new HtpbeApiException(502, "BAD_RESPONSE", "analyze response missing id");

        return payload.Id;
    }

    private async Task<AnalysisResult> GetResultAsync(string id, CancellationToken ct)
    {
        // POST /analyze runs the analysis synchronously, so the result is normally
        // ready on the first GET. The bounded poll below is defensive: it tolerates
        // a brief replication lag and re-reads on a transient 404 before giving up.
        HtpbeApiException? lastError = null;

        for (var attempt = 1; attempt <= _maxPollAttempts; attempt++)
        {
            using var response = await _http.GetAsync($"result/{id}", ct);

            if (response.IsSuccessStatusCode)
            {
                var result = await response.Content.ReadFromJsonAsync<AnalysisResult>(JsonOptions, ct);
                return result ?? throw new HtpbeApiException(502, "BAD_RESPONSE", "empty result body");
            }

            lastError = await ParseErrorAsync(response, ct);

            // Only a 404 is worth re-reading (the row may not be visible yet).
            // Every other error is terminal — surface it without burning attempts.
            if (response.StatusCode != HttpStatusCode.NotFound)
                throw lastError;

            await Task.Delay(TimeSpan.FromMilliseconds(300 * attempt), ct);
        }

        throw lastError ?? new HtpbeApiException(504, "RESULT_TIMEOUT", "result not ready after polling");
    }

    private static async Task<HtpbeApiException> ParseErrorAsync(
        HttpResponseMessage response, CancellationToken ct)
    {
        var statusCode = (int)response.StatusCode;
        var code = "UNKNOWN";
        var message = response.ReasonPhrase ?? statusCode.ToString();

        try
        {
            var error = await response.Content.ReadFromJsonAsync<ErrorBody>(JsonOptions, ct);
            if (error is not null)
            {
                code = error.Code ?? code;
                message = error.Error ?? message;
            }
        }
        catch (JsonException)
        {
            // body was not JSON — keep the status-derived defaults
        }

        message = statusCode switch
        {
            401 => "invalid API key — check Htpbe:ApiKey",
            402 => "no credits available for this key — top up or subscribe",
            403 => "test key sent to a live URL, or vice versa",
            413 => "PDF exceeds the 10 MB size limit",
            422 => "the URL did not return a valid PDF file",
            _ => message,
        };

        int? retryAfter = null;
        if (statusCode == 429 && response.Headers.RetryAfter is { } ra)
            retryAfter = ParseRetryAfter(ra);

        return new HtpbeApiException(statusCode, code, message, retryAfter);
    }

    // Handles both the delta-seconds form and the HTTP-date form,
    // clamped to [1, 600]. Returns null when neither is present.
    private static int? ParseRetryAfter(System.Net.Http.Headers.RetryConditionHeaderValue ra)
    {
        if (ra.Delta is { } delta)
            return Math.Clamp((int)delta.TotalSeconds, 1, 600);
        if (ra.Date is { } date)
            return Math.Clamp((int)(date - DateTimeOffset.UtcNow).TotalSeconds, 1, 600);
        return null;
    }

    private sealed record ErrorBody
    {
        public string? Error { get; init; }
        public string? Code { get; init; }
    }
}

Two status codes deserve explicit handling in your own code:

  • 402 (Payment Required) — the key has no credit source left. Credits are universal: a subscription’s monthly quota, a one-time top-up batch, and the welcome credits all draw from one pool. A 402 means all three are exhausted (or there is no active plan on a live key). HtpbeApiException.Retryable returns false for it — surface it to your billing path rather than retrying, because retrying fails identically until the account is topped up at the pricing page.
  • 429 (Too Many Requests) — this is server-wide concurrency, not per-key rate limiting. The response carries a Retry-After header, which ParseRetryAfter reads (both delta-seconds and HTTP-date forms, clamped to [1, 600]) and stashes on the exception. Your retry policy reads that value before falling back to exponential backoff.

Step 6: Register the Client and Retry on Transient Failures Only

Register HtpbeClient as a typed client. IHttpClientFactory manages the underlying handler pool, sets the base address and default headers once, and lets you attach a resilience policy. The policy retries only on the transient codes — 5xx and 429 — and never on the permanent 4xx codes like 401, 402, or 422, where retrying would burn latency and credits without ever succeeding.

using System.Net;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;

// In Program.cs

builder.Services
    .AddHttpClient<HtpbeClient>((sp, http) =>
    {
        var opts = sp.GetRequiredService<IOptions<HtpbeOptions>>().Value;
        http.BaseAddress = new Uri(opts.BaseUrl);
        http.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiKey);
        http.DefaultRequestHeaders.Accept.Add(
            new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
        http.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds);
    })
    .AddPolicyHandler(TransientRetryPolicy());

static IAsyncPolicy<HttpResponseMessage> TransientRetryPolicy() =>
    HttpPolicyExtensions
        // Network failures and 5xx are handled by HandleTransientHttpError;
        // add 429 explicitly so a capacity signal also backs off and retries.
        .HandleTransientHttpError()
        .OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: (attempt, response, _) =>
            {
                // Honour a server-supplied Retry-After when present (429 capacity),
                // otherwise fall back to exponential backoff: 1s, 2s, 4s.
                var retryAfter = response.Result?.Headers.RetryAfter?.Delta;
                return retryAfter ?? TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
            },
            onRetryAsync: (_, _, _, _) => Task.CompletedTask);

If you would rather not take a Polly dependency, the bounded poll inside GetResultAsync already handles the most common transient case (a result that is not yet visible), and you can wrap VerifyAsync in a small for loop that re-throws when HtpbeApiException.Retryable is false. For most integrations the Polly handler is the cleanest fit, because it sits at the HttpClient layer and applies to both the submit and the read uniformly.

Step 7: The Bank-Statement Gate

The client returns facts. Turning those facts into an Accept / Reject / Review decision is a policy choice that depends on what the document claims to be. A bank statement, a payslip, or a diploma claims institutional origin, so anything other than intact should stop the automated path. A user-generated form is held to a looser standard.

namespace Htpbe;

public enum Decision { Accept, Reject, Review }

public static class DocumentGate
{
    /// <summary>
    /// Maps a verdict to a decision for documents that claim institutional
    /// origin (bank statements, payslips, diplomas). For these, "inconclusive"
    /// is treated as strictly as "modified": a document that should have come
    /// from a bank's own system but looks like it was built in Word does not
    /// get the benefit of the doubt.
    /// </summary>
    public static Decision ForInstitutional(AnalysisResult r) =>
        VerdictParser.Parse(r.Status) switch
        {
            Verdict.Modified => Decision.Reject,
            // A bank statement that comes back inconclusive should not be
            // auto-accepted: it typically came from consumer software rather
            // than a bank's own system — a signal to route for review, not
            // proof of tampering. Send it to a human.
            Verdict.Inconclusive => Decision.Review,
            Verdict.Intact => Decision.Accept,
            _ => Decision.Review,
        };
}

Wire the client and the gate into a minimal-API endpoint that accepts a JSON body with a reachable URL. The handler runs the check before any business logic touches the file:

// In Program.cs, after building the app

app.MapPost("/api/documents/verify", async (
    VerifyRequest req, HtpbeClient client, CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(req.DocumentUrl))
        return Results.BadRequest(new { error = "documentUrl is required" });

    AnalysisResult result;
    try
    {
        result = await client.VerifyAsync(req.DocumentUrl, req.OriginalFilename, ct);
    }
    catch (HtpbeApiException e)
    {
        return MapApiError(e);
    }

    return DocumentGate.ForInstitutional(result) switch
    {
        Decision.Reject => Results.UnprocessableEntity(new
        {
            decision = "reject",
            reason = "document modified after creation",
            modification_markers = result.ModificationMarkers,
        }),
        Decision.Review => Results.Accepted(value: new
        {
            decision = "review",
            status_reason = result.StatusReason,
        }),
        _ => Results.Ok(new { decision = "accept", check_id = result.Id }),
    };
});

static IResult MapApiError(HtpbeApiException e) => e.StatusCode switch
{
    // Configuration / billing errors — never leak the cause to the caller.
    401 or 402 => Results.Problem(
        "verification temporarily unavailable", statusCode: 503),
    422 => Results.UnprocessableEntity(
        new { error = "the URL did not return a valid PDF" }),
    413 => Results.Problem("PDF must be under 10 MB", statusCode: 413),
    _ => Results.Problem("verification failed", statusCode: 502),
};

public sealed record VerifyRequest(string DocumentUrl, string? OriginalFilename);

An inconclusive result should not be auto-accepted — it typically indicates the file came from consumer software, an online editor, an HTML renderer, or a scanner rather than an institutional generator. That is a signal to route for review, not proof of tampering. For a deeper explanation, see what “inconclusive” really means. For documents that claim institutional origin, treat inconclusive with the same caution as modified: do not accept automatically, route to a human reviewer. Inverting that policy — treating inconclusive as a pass — is the single most common integration mistake, because it hands an automatic accept to exactly the consumer-software-built documents a bank statement should never be.

Step 8: Giving the API a Reachable URL

The API does not accept file uploads — it downloads the PDF from a URL you supply, so the file must be publicly reachable for the few seconds the analysis takes. The cleanest pattern is a short-lived presigned URL from your object store: you never expose the bucket, the link expires in minutes, and passing originalFilename keeps the audit trail readable instead of showing an opaque storage key.

// Store the upload privately, mint a 5-minute presigned GET URL, verify.
var key = $"incoming/{Guid.NewGuid()}.pdf";

await s3.PutObjectAsync(new PutObjectRequest
{
    BucketName = bucket,
    Key = key,
    InputStream = pdfStream,
    ContentType = "application/pdf",
}, ct);

var presignedUrl = await s3.GetPreSignedURLAsync(new GetPreSignedUrlRequest
{
    BucketName = bucket,
    Key = key,
    Expires = DateTime.UtcNow.AddMinutes(5),
    Verb = HttpVerb.GET,
});

var result = await client.VerifyAsync(presignedUrl, originalFilename, ct);

The same pattern works with Azure Blob Storage (a SAS token via BlobClient.GenerateSasUri), Google Cloud Storage (UrlSigner), or Cloudflare R2 (S3-compatible — reuse the AWS SDK with the R2 endpoint). One security note: the API fetches whatever URL you give it, so if a URL ever comes from untrusted input (a user-pasted link, a webhook payload), validate that it resolves to a public host first — reject localhost, 169.254.169.254 (cloud metadata), and the RFC 1918 private ranges to close the SSRF surface. When you mint the URL yourself from a private bucket the risk is minimal, but the validation belongs in the request flow either way.

Step 9: Testing Without Burning Quota

Every plan includes a test API key (prefix htpbe_test_) that accepts only mock URLs of the form https://api.htpbe.tech/v1/test/{filename}.pdf and returns deterministic responses — like Stripe test cards, with no quota cost. Point an integration test at these fixtures to cover every branch of the gate. With WebApplicationFactory<Program> you exercise the real HtpbeClient, configured with the test key:

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

public sealed class HtpbeClientTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HtpbeClient _client;

    public HtpbeClientTests(WebApplicationFactory<Program> factory)
    {
        var configured = factory.WithWebHostBuilder(builder =>
            builder.UseSetting("Htpbe:ApiKey",
                Environment.GetEnvironmentVariable("HTPBE_TEST_API_KEY")!));
        _client = configured.Services.GetRequiredService<HtpbeClient>();
    }

    [Fact]
    public async Task CleanDocumentReturnsIntact()
    {
        var r = await _client.VerifyAsync("https://api.htpbe.tech/v1/test/clean.pdf");

        Assert.Equal("intact", r.Status);
        Assert.Empty(r.ModificationMarkers);
        Assert.Equal(Decision.Accept, DocumentGate.ForInstitutional(r));
    }

    [Fact]
    public async Task SignatureRemovedIsRejected()
    {
        var r = await _client.VerifyAsync("https://api.htpbe.tech/v1/test/signature-removed.pdf");

        Assert.Equal("modified", r.Status);
        Assert.True(r.SignatureRemoved);
        Assert.Equal(Decision.Reject, DocumentGate.ForInstitutional(r));
    }

    [Fact]
    public async Task InconclusiveIsRoutedToReview()
    {
        var r = await _client.VerifyAsync("https://api.htpbe.tech/v1/test/inconclusive.pdf");

        Assert.Equal("inconclusive", r.Status);
        Assert.NotNull(r.StatusReason);
        Assert.Equal(Decision.Review, DocumentGate.ForInstitutional(r));
    }
}

Useful fixtures: clean.pdfintact, signature-removed.pdfmodified, dates-mismatch.pdfmodified, and inconclusive.pdfinconclusive. For pure unit tests of the endpoint and gate without any network, inject a fake HttpMessageHandler into the HtpbeClient and return canned JSON, or test DocumentGate.ForInstitutional directly against a hand-built AnalysisResult. Keep test and live keys in separate configuration sources and never commit either.

For audit dashboards, GET /api/v1/checks returns a paginated list of every result for your key — filter by status and limit (/checks?status=modified&limit=50, same Authorization header). When you reach your monthly quota, further requests return 402 PAYMENT_REQUIRED until it resets — add a one-time credit pack or move to a higher tier to keep going, and handle the 402 so a quota boundary never silently drops a check.

What the Verdicts Mean

The whole signal is three verdicts and a list of named markers. Encoding them correctly in your DocumentGate matters more than any other choice in the integration:

  • intact — no post-creation modification was detected and the origin looks institutional. Safe to accept on the automated path.
  • modified — forensic evidence of an edit after the document was created. The modification_markers array names the signal: HTPBE_DATES_DISAGREE for inconsistent internal timestamps, HTPBE_SIGNATURE_REMOVED for a stripped digital signature, HTPBE_POST_SIGNATURE_EDIT for changes made after signing, HTPBE_MULTIPLE_REVISION_LAYERS for a document saved repeatedly after creation. Reject, or route to fraud review.
  • inconclusive — the document was built with consumer software, an online editor, an HTML renderer, or a scanner, so there is no institutional “original” to verify integrity against. This is not a failure and not a clean pass — it is a routing signal. For a document that should have come from an institution (a bank statement, a payslip), inconclusive means it did not, which is exactly why DocumentGate.ForInstitutional routes it to a human reviewer.

What This Does Not Catch

Structural analysis has honest limits, and an ASP.NET Core service making automated decisions should encode them rather than overstate the verdict:

  • Content fabricated in one pass. If someone opens Word, types a false salary, and exports once, the file was never modified after creation — it is structurally consistent. The fraud happened at authorship, not at the byte level. This is exactly why a payslip from a consumer tool tends to return inconclusive rather than intact: the analysis cannot vouch for a document anyone could have produced from scratch.
  • Born-synthetic forgeries. A fake document generated programmatically with a valid-looking account number and a real logo — never derived from a genuine original — has no post-creation edit to detect. Catching that is a content-verification problem (does this account number exist, does this employer match payroll records), a different product category from structural tamper detection.
  • Documents rebuilt from scratch in the original’s software. A determined attacker who recreates a document in the same institutional tool and matches the metadata leaves few structural signals. This is rare and high-effort, but possible.
  • Encrypted or password-protected PDFs. The service cannot parse a file it cannot open; remove the password before submitting.

These limits are why structural tamper detection works as one layer in a fraud-detection stack, not the whole stack. Pair the structural verdict with domain checks — amount validation, account-number lookups, sender authentication, and your KYC or OCR provider — for a layered defense. The structural layer answers a question identity verification cannot: was this file edited after it was issued? See PDF Fraud Prevention Best Practices.

Decisions Before You Ship

The integration surface is intentionally small: one POST, one GET, three verdicts, the typed exception above. The complexity lives on the .NET side, and two choices matter most:

  • Where verification runs. Synchronous inside the request handler gives the caller an immediate decision but blocks for a few seconds; a background IHostedService or a message-driven consumer (Azure Service Bus, RabbitMQ) returns instantly and defers the verdict. Sync suits low-volume B2B onboarding; async suits high-volume portals. Because the call is fully async/await and the analysis takes 2–5 seconds, the synchronous path is cheap enough for most onboarding flows.
  • inconclusive routing. For documents that claim institutional origin (bank statements, diplomas, payslips), treat inconclusive with the same caution as modified and route to human review — that is what DocumentGate.ForInstitutional encodes. For genuinely user-generated content it may be acceptable as-is, so you may want a second gate with a looser policy.

To start, sign up for HTPBE? — new accounts get five checks to try, then pay-per-check credits or a subscription (see current pricing) — copy your test key, and run the curl call from Step 1. The full API reference documents every response field, error code, and the marker dictionary the .NET client branches on.

Share This Article

Found this article helpful? Share it with others to spread knowledge about PDF security and fraud detection.

https://htpbe.tech/blog/pdf-verification-csharp-dotnet-integration-guide

Secure your workflow

Create your account — API key on signup, free test environment on every plan.
From $15/mo. No sales call. Cancel any time.