Command Palette

Search for a command to run...

API Debugging Tools: The Six Tabs I Open When an Endpoint Lies to Me

API Debugging Tools: The Six Tabs I Open When an Endpoint Lies to Me

T
Toolz Team
|Jul 5, 2026|24 min read

A license-activation endpoint on one of my Laravel projects started rejecting every token it was handed. Not some tokens. Every token, including ones the same server had issued ninety seconds earlier. The logs said token expired. The tokens were not expired. I spent most of a Saturday convinced the clock on the box had drifted.

It hadn't. The bug was one line:

if (payload.exp < Date.now()) throw new Error('token expired')

exp in a JWT is seconds since the epoch โ€” RFC 7519 ยง4.1.4 is unambiguous about that. Date.now() in JavaScript returns milliseconds. So I was comparing a ten-digit number against a thirteen-digit number, and ten-digit numbers are always smaller. Every token in the universe was expired, forever. The fix was Date.now() / 1000. The diagnosis took eight hours because I never actually looked at the token โ€” I kept re-reading my own code, which is the debugging equivalent of searching for your glasses while wearing them.

What finally broke the loop was pasting the token into a decoder, reading exp: 1748952000, converting that to a date, and seeing a timestamp two weeks in the future. The token was fine. My comparison was wrong. Thirty seconds of looking at data beat eight hours of looking at code.

That's what this guide is about. Not clever debugging philosophy โ€” the specific, boring, unglamorous browser tools I keep in a tab group called "API Debug," what each one is actually for, and the failure modes they catch. Everything here runs client-side on toolz.dev, which matters more than it sounds like it does when the thing you're about to paste is a production bearer token.

TL;DR: When an API misbehaves, stop reading your code and start reading the payload. Format the response with the JSON Formatter. Crack the token with the JWT Decoder, not a generic Base64 tool. Turn exp, iat, and X-RateLimit-Reset into real dates with the Timestamp Converter. Compare a working response against a broken one with the Text Diff or, better, the JSON Diff. Decode query-string mangling with the URL Encoder. All of it runs in your browser โ€” the token never goes over the wire.


Why Does Debugging an API Feel So Much Worse Than Debugging Code?

Because you can't step through it. A local bug has a stack trace, a debugger, and a breakpoint. An API bug has a string. Somebody else's server produced that string, according to rules you only half know, and your job is to work backwards from it.

That inverts the usual skill. The bottleneck isn't logic, it's legibility. Almost every API bug I've chased in the last few years was invisible until I made the data readable:

  • A minified 4,000-character JSON response that turned out to have "data": null buried at depth six.
  • A Base64 payload that decoded to an error message the API was too polite to put in the status code.
  • A webhook that failed signature verification because the body had a trailing newline my HTTP client was helpfully adding.
  • A timestamp that was in milliseconds when the docs said seconds. (Twice. Different companies.)

None of these were hard problems. All of them were unreadable problems. The tools below exist to make the data legible fast enough that you notice the thing your eyes would otherwise slide past.

Which Tool for Which Symptom?

This is the table I wish someone had handed me five years ago. Symptom on the left, first move on the right.

The symptom What's usually true First move
Response is one giant line, can't see structure Nothing's broken, it's just minified JSON Formatter
401/403 on a token you just minted Clock, claim, or comparison bug JWT Decoder โ†’ check exp, aud, iss
Date shows as 1970 or year 56122 Seconds/milliseconds mismatch Timestamp Converter
"It worked yesterday" One field changed shape JSON Diff old vs new response
Params arrive mangled or truncated Double-encoding, or an unescaped &/+ URL Encoder
Webhook signature never matches Body bytes differ from what you're hashing Hash Generator on the exact raw body
Authorization: Basic ... rejected Credentials encoded wrong, or a stray space Base64 Converter
Config-driven deploy fails, API never even runs YAML indentation YAML Validator
Two responses look identical but behave differently Invisible character Text Diff

Everything below is the long version of that table.


How Do I Make an API Response Readable in Ten Seconds?

Paste it into the JSON Formatter. That's the whole technique, and I'm not being glib โ€” the single highest-leverage debugging habit I have is refusing to reason about a payload I haven't formatted.

Here's a response shape I get from a billing provider, exactly as it comes off the wire:

{"subscriptions":[{"id":"sub_7f3d8a2b","status":"active","plan":{"id":"pro_annual","interval":"year","amount":9900},"current_period_end":1748952000,"cancel_at_period_end":false}],"has_more":false}

Formatted, it's a different object entirely โ€” not to the parser, but to me:

{
  "subscriptions": [
    {
      "id": "sub_7f3d8a2b",
      "status": "active",
      "plan": {
        "id": "pro_annual",
        "interval": "year",
        "amount": 9900
      },
      "current_period_end": 1748952000,
      "cancel_at_period_end": false
    }
  ],
  "has_more": false
}

Now I can see that amount is 9900 and not 99.00 โ€” it's in cents, which is the single most common integration bug in payments โ€” and that current_period_end is a ten-digit integer, which means seconds, which means don't hand it to new Date() directly.

Validation catches what your eyes won't

Formatting also validates. A parse failure is information. The errors that actually show up in real payloads:

  • Trailing commas. Legal in JavaScript, illegal in JSON (per RFC 8259). Hand-edited fixtures are full of them.
  • Single quotes. JSON requires double quotes. Python's str(dict) output is not JSON, no matter how much it looks like it.
  • Unquoted keys. Same story โ€” that's a JavaScript object literal, not JSON.
  • NaN / Infinity. Some serializers emit them. JSON has no such literals.
  • A BOM. A UTF-8 byte-order mark in front of { will make a strict parser reject a document that looks perfect on screen.

If your JSON is valid but the shape is wrong, that's a different tool โ€” see the diff section below.

Why Use a JWT Decoder Instead of Just Base64-Decoding the Token?

You can Base64-decode a JWT by hand. I did it for years. It's a bad habit, and here's why.

A JWT (RFC 7519) is three chunks separated by dots: header, payload, signature. Each chunk is base64url, not standard Base64 โ€” RFC 4648 ยง5, the URL-safe alphabet that swaps +โ†’- and /โ†’_ and usually drops the = padding. Feed that to a strict standard-Base64 decoder and it will either error or silently give you garbage bytes. So the hand method means splitting on dots, re-padding, and swapping the alphabet, every single time, and then eyeballing raw JSON.

The JWT Decoder does all of that in one paste and โ€” the part that actually saves time โ€” presents the claims as claims. The ones I check, in order:

  • exp (expiration) and iat (issued at) โ€” both NumericDate, i.e. seconds since 1970-01-01 UTC. This is the field that ate my Saturday.
  • aud (audience) โ€” a token minted for your staging API will be structurally perfect and still rejected by prod.
  • iss (issuer) โ€” after an identity-provider migration, this is the field that quietly changed.
  • alg in the header โ€” if it says none, you have a security problem, not a debugging problem.

Take the canonical example token everyone's seen:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header: {"alg":"HS256","typ":"JWT"}. Payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}. And iat there is 2018-01-18T01:30:22Z โ€” which you only know by running it through a timestamp converter, which is the whole point of the next section.

The thing a decoder does not do

Decoding is not verifying. Anyone can decode a JWT; the payload is encoded, not encrypted. A decoder tells you what the token claims, never whether the claim is true. Signature verification happens on your server with your secret, and no browser tool should ever be handed that secret. Treat a decoded JWT the way you'd treat a form submission: as an assertion from a stranger.

How Do I Stop Getting Timestamps Wrong?

Learn to read the digit count. This is the cheapest debugging skill in the entire field and it takes one minute to learn.

Digits Unit Example new Date(x) in JS gives you
10 seconds 1748952000 1970-01-21 โ€” obviously wrong
13 milliseconds 1748952000000 The correct date
16 microseconds 1748952000000000 Nonsense

Ten digits means seconds. Thirteen means milliseconds. JavaScript's Date constructor wants milliseconds; Unix, Python's time.time(), Go's Unix(), PHP's time(), and most APIs' exp fields speak seconds. Everything downstream of that mismatch is chaos.

And the chaos is loud in one direction and silent in the other. Feed seconds to a millisecond parser and you get 1970 โ€” a bug so obvious you fix it in a minute. Feed milliseconds to a seconds parser and you get the year 56122. I checked: 1708876200000, interpreted as seconds, lands on 17 February in the year 56122. That's the direction that quietly ships, because nothing throws โ€” a subscription simply never expires and nobody notices for a quarter.

The Timestamp Converter exists so you can settle this in one paste instead of arguing about it. Paste 1748952000, read the date, move on. Paste the X-RateLimit-Reset header your API is complaining about and find out you have four minutes to wait, not four hours. If the timestamp is naive (no Z, no offset) and you need to reason about what it means in another region, the Timezone Converter is the follow-up.

Two more timestamp traps worth knowing

The 2038 problem is real and dated. A signed 32-bit seconds counter overflows at 2147483647, which is 2038-01-19T03:14:07Z. Any system still storing time in a signed 32-bit int โ€” and there are more of them in embedded and legacy database columns than anyone wants to admit โ€” breaks then. If you're setting long-lived expirations today, you are already able to hit this.

Naive timestamps are a lie by omission. 2026-02-25T14:30:00 with no trailing Z and no +05:30 is not a moment in time; it's a moment in an unspecified place. I treat any API that returns naive timestamps as a bug report waiting to be filed. Prefer RFC 3339 (2026-02-25T14:30:00Z), which is the strict, unambiguous profile of ISO 8601 that the web actually uses.

What Do I Do When "It Worked Yesterday"?

Diff it. Don't theorize โ€” diff it.

Capture the response from the working environment (or dig the last good one out of your logs) and the response from the broken one, and put them side by side. Nine times out of ten there is exactly one difference and it's staring at you within five seconds.

For JSON, reach for the JSON Diff before the text diff. It parses both sides and compares structure, which means reordered keys and different indentation don't show up as changes โ€” only real ones do. A text diff of two JSON documents that a server serialized in a different key order will light up like a christmas tree and tell you nothing.

For everything that isn't JSON โ€” headers, raw bodies, config files, curl output โ€” use the Text Diff. Its specialty is the class of change your eyes are physically incapable of catching: a trailing space, a tab that became four spaces, a CRLF line ending that snuck in from a Windows machine, a curly quote that a documentation site substituted for a straight one when you copied the example. I've written a whole piece on why eyeballing text comparisons fails, because it cost me a support escalation once.

Why Do My Query Parameters Keep Arriving Broken?

Because URL encoding has three or four subtly different flavors and everybody's stack picks a different one.

The classics, in rough order of how often they've bitten me:

  • + versus %20. In a query string, + historically means a space (the application/x-www-form-urlencoded convention). In a path segment, + means a literal plus. So a base64 signature containing +, dropped into a query string unencoded, arrives with spaces in it and the signature check fails. This is a genuinely nasty one because the value looks right in the logs.
  • Double encoding. %2F becomes %252F because two layers of your stack both helpfully encoded it. The symptom is a parameter that gains percent-signs each time it passes through a proxy.
  • A raw & inside a value. Splits your parameter in two. Now name=Ben & Jerry is name=Ben plus a mystery param called Jerry.

Paste the URL into the URL Encoder and decode it. If decoding it once still leaves percent-escapes behind, you've found your double encoding. That's the whole diagnostic.

How Do I Debug a Webhook That Won't Verify?

This is the one that separates "I understand HTTP" from "I've been on call."

Nearly every webhook provider signs the payload with an HMAC and puts the result in a header. Your job is to compute the same HMAC and compare. When it doesn't match, the signature is almost never the problem. The bytes are the problem. You are not hashing what they hashed.

The usual suspects:

  1. You hashed the parsed-and-re-serialized body. Your framework parsed the JSON into an object, you called JSON.stringify() on it, and now the key order or the whitespace differs by one character. You must hash the raw request body, bytes as received. In Express that means capturing the raw buffer before express.json() gets to it; in Laravel it means $request->getContent(), not $request->all().
  2. A trailing newline. Some clients append one. The provider didn't.
  3. You're hashing the hex-encoded string instead of the raw bytes, or comparing hex against base64.
  4. Charset. The body has a multi-byte character and something along the way transcoded it.

The Hash Generator is how I isolate this: take the exact body string, hash it, compare against what my code produced for what it thought was the same body. If those two hashes differ, my code is not seeing the bytes I think it's seeing, and the problem was never cryptographic at all. (Also worth knowing: if a provider still offers MD5 or SHA-1 signatures, that's a signal about the age of their platform. SHA-256 is the floor now.)

What Else Lives in the Debug Tab Group?

The supporting cast โ€” less glamorous, still earns its place:

  • UUID Generator โ€” for a clean X-Request-ID on every test call, so you can grep it across three services' logs afterwards. Worth knowing that UUIDs got a spec refresh: RFC 9562 (2024) obsoleted RFC 4122 and standardized UUIDv7, which is time-ordered and therefore vastly kinder to your database's B-tree index than random v4. If you're picking an ID scheme for a new table today, that's the one to read up on. There's a longer breakdown of UUID versions if you want it.
  • Base64 Converter โ€” for Authorization: Basic headers (RFC 7617: it's base64(user:password), and yes, that's encoding, not security โ€” TLS is what protects it), and for the inline binary blobs some APIs stuff into JSON fields. Base64 costs you ~33% size overhead, which is why an "unexpectedly large" payload often just has a file in it. The full Base64 guide covers the base64url distinction that trips people up.
  • YAML Validator โ€” because half my API failures in the last year weren't API failures. They were a two-space indentation error in a CI config, and the endpoint never got deployed at all. (YAML has a nastier failure mode than a broken build, though: valid YAML that means something you didn't intend. I wrote up why version: 1.10 becomes 1.1 after it cost me a deploy.)
  • Regex Tester โ€” for the moment you need to pull a request ID out of 900 lines of log with a pattern you're not confident about.
  • CSV Viewer โ€” for the export endpoint whose output you need to sanity-check before someone imports it into production.

Does It Actually Matter That These Run In The Browser?

Yes, and I'd say this even if I hadn't built the site.

Think about what you paste into a debugging tool. A JWT โ€” which is a live credential until it expires. A production API response, which is customer data: names, emails, subscription states. A webhook body, which may contain a payment record. A curl command with an Authorization header still in it.

Now consider that a server-side tool, by definition, receives all of that. Not maliciously โ€” just architecturally. The paste goes into an HTTP request, hits someone's backend, and lands in whatever logging they happen to run. Even a scrupulously honest operator ends up with your bearer token in an access log they never intended to keep.

The tools on toolz.dev do the work in JavaScript in your tab. Nothing is uploaded, because there's nowhere to upload it to โ€” the parsing, the decoding, the hashing all happen on your machine. You don't have to take my word for that, either: open DevTools, go to the Network tab, paste a token, and watch for a request that never comes. That's a thirty-second audit, and you should run it on any tool you paste secrets into, mine included. I wrote up how to verify a client-side tool properly for exactly this reason.

If your organization handles EU personal data, this isn't only hygiene โ€” pasting customer records into a third-party server is a processing activity, with all the GDPR paperwork that implies. Client-side tools sidestep the question by never becoming a processor at all.

A Workflow That Actually Sticks

Six steps, in the order I run them when something's on fire:

  1. Capture the raw response. Full body, full headers, status code. Not your app's interpretation of it โ€” the actual bytes. curl -i or the Network tab's "Copy as cURL."
  2. Format it. JSON Formatter. Look at the shape before you look at the values. Is the field you need even present?
  3. Decode every opaque string. Tokens through the JWT Decoder, Base64 blobs through the Base64 Converter, mangled URLs through the URL Encoder. Opaque strings hide the answer surprisingly often.
  4. Turn every number that could be a time into a date. Timestamp Converter. Count the digits first.
  5. Diff against a known-good response. JSON Diff. If you don't have a known-good response, this is your sign to start saving them.
  6. Only now go read your code. By this point you usually know the line before you open the file.

The order matters. Step 6 is where I used to start, and it's why that Saturday took eight hours.


Frequently Asked Questions

What are the best free tools for debugging APIs?

For day-to-day API debugging you need five things: a JSON formatter and validator, a JWT decoder, a Unix timestamp converter, a diff tool, and a URL encoder/decoder. All five are free on toolz.dev and run entirely in the browser. Add a hash generator if you work with signed webhooks, and a YAML validator if your deploys are config-driven.

Is it safe to paste a JWT or API response into an online tool?

Only if the tool is client-side. A JWT is a live credential and an API response is usually customer data, so a server-side tool means shipping both to a stranger's backend. The toolz.dev tools process everything in your browser with JavaScript and send nothing over the network โ€” verify this yourself by opening DevTools' Network tab while you paste. Run that same check on any tool you use with sensitive data.

Why does my JWT say "expired" when I just generated it?

The most common cause is a units mismatch. The exp claim is in seconds (RFC 7519 defines it as a NumericDate), but JavaScript's Date.now() returns milliseconds, so comparing them directly makes every token look expired. Decode the token, read exp, convert it to a real date with a timestamp converter, and check whether it is actually in the past before you touch your auth code.

How do I tell if a Unix timestamp is in seconds or milliseconds?

Count the digits. Ten digits is seconds, thirteen is milliseconds, sixteen is microseconds. If a date comes out in 1970 you fed seconds to a millisecond parser; if it comes out in the year 56122 you fed milliseconds to a seconds parser. The second mistake is more dangerous because nothing throws an error.

Can I use these tools to debug GraphQL APIs?

Yes. GraphQL responses are JSON, so the JSON formatter and JSON diff work unchanged, and GraphQL typically uses the same bearer-token authentication you'd decode with the JWT decoder. The only real difference is that GraphQL returns HTTP 200 with an errors array instead of a non-2xx status, so always format the body โ€” the failure is inside the payload, not in the status code.

Why does my webhook signature verification always fail?

Almost always because you are hashing different bytes than the provider did. If your framework parsed the JSON body and you re-serialized it before hashing, the whitespace or key order has changed and the HMAC will never match. Hash the raw request body exactly as received, and check for a trailing newline added by your HTTP client.

What is the difference between Base64 and base64url encoding?

Standard Base64 (RFC 4648 ยง4) uses + and / in its alphabet and pads with =. base64url (RFC 4648 ยง5) replaces those with - and _ and usually drops the padding, so the value is safe to put in a URL or a JWT. Feeding base64url data to a strict standard-Base64 decoder produces an error or garbage, which is why a dedicated JWT decoder beats hand-decoding a token.

How do I debug a 401 Unauthorized response?

Work outward from the token. Decode it and check exp against the current time โ€” an expired token is the single most common cause, and it is invisible until you convert the claim to a readable date. If the token is live, check the Authorization header itself: the scheme must be present and spelled correctly (Bearer <token>, one space, no quotes), and a token pasted from a terminal often carries a trailing newline that breaks the match. After that, confirm the aud and iss claims match what the API expects, since a valid token issued for a different audience is rejected exactly like a bad one. Only then start suspecting the server.

How do I decode a JWT without a library?

A JWT is three base64url segments joined by dots. Split on the dots, then base64url-decode the first two โ€” the header and the payload โ€” and both come out as plain JSON. The third segment is the signature, and it does not decode into anything readable because it is raw bytes rather than text. This matters more than it sounds: decoding a token tells you what it claims, not whether those claims are true. Verifying the signature requires the issuer's key and belongs in your server code, never in a browser tool. Read tokens here to debug; validate them in the application.

Do I still need Postman or Insomnia if I use these tools?

Yes โ€” they solve different problems. An API client sends requests; these tools make the responses legible. In practice I use the client to fire the request and copy the raw output, then move to browser tools to format, decode, convert, and diff it. They sit next to each other in the workflow rather than replacing one another.

Comments

0 comments

0/2000 characters

No comments yet. Be the first to share your thoughts!