You are reading a webhook payload and you hit this:
{
"id": "evt_8813",
"type": "subscription.renewed",
"createdAt": 1735689600
}
The only question you have is: what date is that?
It's 2025-01-01T00:00:00Z — midnight UTC on New Year's Day. A Unix timestamp is a count of seconds since 1970-01-01T00:00:00Z, so a ten-digit number in the 1.7–1.8 billion range is "sometime in the mid-2020s," and this particular one lands exactly on a day boundary. Here's how to get there without a converter, and the two ways this number lies to you.
Why an integer instead of a date string
The epoch format wins on three properties that a formatted string never gets:
- No timezone.
1735689600means the same instant everywhere.2025-01-01 00:00:00means nothing until someone tells you which offset produced it, and in practice nobody does. - No parsing. Comparing two instants is
a < b. Computing a duration isb - a. There is no calendar involved, no month-length table, no locale. - No ambiguity. Nothing in it can be read as day-first or month-first.
The one thing it is not is a true count of elapsed seconds. POSIX defines "Seconds since the Epoch" with a formula that hard-codes 86,400 seconds per day, so leap seconds simply do not exist in Unix time — 27 have been inserted into UTC since 1972 and the count skipped every one, reusing the same value twice. That only bites if you're measuring sub-second intervals across a leap second boundary, and it's on the way out anyway: the 27th CGPM voted in 2022 to stop inserting them by 2035.
Worked example: converting 1735689600 by hand
The whole conversion is two divisions and one leap-year count.
| Step | Operation | Result |
|---|---|---|
| 1. Split days from time | 1735689600 ÷ 86400 | 20089 days, remainder 0 |
| 2. Time of day | remainder 0 seconds | 00:00:00 UTC |
| 3. Rough year | 20089 ÷ 365.2425 | 55.0 → 1970 + 55 = 2025 |
| 4. Check the leap days | 55 × 365 = 20075; 20089 − 20075 | 14 — exactly the leap days from 1972 to 2024 |
| 5. Remaining days in 2025 | 20089 − 20089 | 0 → January 1 |
Step 4 is the one that makes this trustworthy rather than approximate. If the leftover after years × 365 doesn't match the number of leap days you'd expect in that span, your year is off by one and you should redo step 3.
That works because the value is a clean day boundary. The moment there's a remainder in step 1 — which is almost always, for a real event — the arithmetic turns into a chore, and the honest move is to paste the value into the Unix timestamp converter and get the ISO 8601, RFC 7231 and named-timezone renderings at once. Do the mental version to sanity-check a value; use a converter to actually read one.
Ten digits or thirteen
Now the part that costs real debugging hours. Take the same instant and add three digits:
new Date(1735689600) // 1970-01-21T02:08:09.600Z ← seconds fed to a ms API
new Date(1735689600000) // 2025-01-01T00:00:00.000Z ← correct
JavaScript's Date is millisecond-based from top to bottom: Date.now() returns milliseconds, and the Date constructor reads its numeric argument as milliseconds. Most of the rest of the Unix world is in seconds. So every time a value crosses a language boundary, someone has to remember a factor of 1000, and eventually someone doesn't.
| Language / API | Call | Unit |
|---|---|---|
| JavaScript | Date.now(), new Date(n) | milliseconds |
| Java | System.currentTimeMillis() | milliseconds |
| C | time(NULL) | seconds |
| Python | time.time() | seconds (float) |
| PHP | time() | seconds |
| Go | time.Now().Unix() / .UnixMilli() | seconds / milliseconds |
| Rust | .as_secs() / .as_millis() | seconds / milliseconds |
| PostgreSQL | EXTRACT(EPOCH FROM now()) | seconds (fractional) |
The canonical version of this bug is JWT expiry. RFC 7519 defines exp and iat as NumericDate — seconds. Write exp: Date.now() + 3600000 and you have issued a token that expires roughly fifty thousand years from now. Every validator on earth will happily accept it, forever, and nothing will look wrong until an audit.
Digit count is the fastest tell, and it works for the entire useful range: a ten-digit value is seconds (and stays ten digits until November 2286), a thirteen-digit value is milliseconds. If a date comes out in January 1970, you fed seconds to a millisecond API. If it comes out in the year 50,000-something, you did the reverse.
What actually happens in 2038
Store a timestamp in a signed 32-bit integer and the largest value you can hold is 2,147,483,647 — which is 2038-01-19T03:14:07Z. One second later the value overflows to −2,147,483,648, and the system decides it is 1901-12-13T20:45:52Z.
This is mostly solved on modern platforms: 64-bit builds have used a 64-bit time_t for years (good for roughly 292 billion years), Linux 5.6 shipped y2038-safe syscalls for 32-bit architectures, and glibc 2.34 gave 32-bit builds a 64-bit time_t behind _TIME_BITS=64. What's left is the long tail, and it's the boring stuff: embedded and IoT firmware nobody recompiles, binary file formats and wire protocols with a fixed 32-bit field, and — the one most likely to bite a web developer — MySQL's TIMESTAMP column, whose documented range still ends at '2038-01-19 03:14:07' UTC. DATETIME has no such limit.
The reason this is not purely a 2038 problem is that software computes future dates all the time. A 30-year mortgage schedule, a certificate lifetime, a retention policy that stamps a delete-after date — anything reaching past January 2038 overflows today, on a 32-bit path, quietly.
FAQ
Why does JavaScript use milliseconds when everything else uses seconds?
It inherited them from java.util.Date, which JavaScript's Date was modelled on in 1995. ECMA-262 defines a time value as an integer number of milliseconds since the epoch, with a range of ±8,640,000,000,000,000 ms — exactly ±100,000,000 days, which puts the maximum representable date at +275760-09-13. JavaScript is perfectly consistent with itself; the breakage is always at the seam with a seconds-based API.
What happens in 2038?
Signed 32-bit timestamps overflow at 2038-01-19T03:14:07Z and wrap to December 1901. 64-bit systems are unaffected, which covers essentially all current servers, phones and desktops. The realistic exposure is 32-bit embedded firmware, fixed-width binary formats, and database columns like MySQL TIMESTAMP that are 32-bit by definition regardless of the host. If any of your data reaches past 2038 — and expiry dates routinely do — check those three now rather than in 2037.
How do negative timestamps work for pre-1970 dates?
They count backwards, and in most runtimes they work exactly as you'd hope: -1 is 1969-12-31T23:59:59Z, and the Apollo 11 landing at 1969-07-20T20:17:40Z is -14182940. JavaScript, Python and Go all handle negative values without complaint. But that's a courtesy, not a guarantee — POSIX itself says that if the year is before 1970 or the value is negative, "the relationship is undefined." Support elsewhere is patchy to match: some older C libraries treated time_t as unsigned, and MySQL's TIMESTAMP range starts at '1970-01-01 00:00:01' UTC, so it cannot store a negative epoch at all. If you're storing birth dates or historical events, use a real date type rather than an epoch integer; you get the pre-1970 range and you stop pretending a birthday happened at a precise instant in UTC.
Back to that log line
"createdAt": 1735689600 is ten digits, so it's seconds; it's in the 1.7 billion range, so it's the mid-2020s; it divides cleanly by 86400, so it's midnight. That's enough to know the renewal fired at the top of 2025 without opening anything. The moment the number doesn't divide cleanly — or you need it in Australia/Sydney — the converter does the tedious half.
Cover photo by Chung-En HU on Pexels.
