Web TippsUse custom web fonts in Google Sheets charts(08.09.2026 um 17:05 Uhr)
Web TippsIntroducing the new 1Password App for Google Chat(08.09.2026 um 18:02 Uhr)
Web TippsUse custom web fonts in Google Sheets charts(08.09.2026 um 17:05 Uhr)
Web TippsIntroducing the new 1Password App for Google Chat(08.09.2026 um 18:02 Uhr)

🔧 Programmierung 🕛 vor 1 Monat 23 Min Lesezeit
0

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

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

Originally published at 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 , , 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 . 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:




CODE
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.




CODE
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.




CODE
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 . 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.




    CODE
    // 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:




    CODE
    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 — new accounts get five checks to try, then pay-per-check credits or a subscription (see documents every response field, error code, and the marker dictionary the .NET client branches on.

    Vollständiger Original-Bericht
    Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
    ↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
3 Quellen
Use custom web fonts in Google Sheets charts
2 Quellen
Introducing the new 1Password App for Google Chat
1 Quelle
Context-aware access controls are available for Gemini Enterprise in the Admin console