# Mark posts in topic as "read'

**URL:** https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852
**Category:** Development
**Tags:** rest-api
**Created:** [January 31, 2026, 3:32pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852 "2026-01-31T15:32:42Z")
**Posts on this page:** 17
**Page:** 1

<div class="post-metadata">

### Author: ![kittenwater](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kittenwater/32/541219_2.png) [@kittenwater](https://meta.discourse.org/u/kittenwater)
#### Post date: [January 31, 2026, 3:32pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/1 "2026-01-31T15:32:42Z")

</div>

I tried to reverse engineer the site by sending POST requests to “https://{hostUrl}/topics/timings” with content-type, csrf token, and user-agent.

Here is what the body (json) looks like:

```python
payload = {
  "topic_id": topic_id,
  "topic_time": post_count * 60000,
  "timings": timings
}

```

It returns a status code of 200 but the read history never changes at `https://{hostUrl}/u/USERNAME/activity/read`

I tried to look into this post but it wasn’t much help:

> [@Mark specific posts as "read" through the API?](https://meta.discourse.org/t/mark-specific-posts-as-read-through-the-api/198701?tl=en):
>
> Hello, I’m pulling posts through the discourse API. I’m using python for that. The request calls the following code (which makes a GET request essentially) and returns the .json similar to [this post](https://meta.discourse.org/t/198701.json) def topic\_by\_id(self, topic\_id, \*\*kwargs): return self.\_get("/t/{0}.json".format(topic\_id), \*\*kwargs) The posts returned have a 'read' flag. The posts I send are read=True, but for the posts I receive they are all marked as read=False, unless I actively log in to Discourse and read the…

Here is a good amount of the code:

```python
def get_csrf(session):
    r = session.get(f"https://{hostUrl}/session/csrf.json")

    if r.status_code != 200:
        raise RuntimeError("Failed to get CSRF")

    data = r.json()

    if "csrf" not in data:
        raise RuntimeError("No CSRF in response")

    return data["csrf"]

def load_topics(session, page):
    print(f"[Topics] Page {page}")

    r = session.get(
        f"https://{hostUrl}/latest.json?page={page}"
    )

    if r.status_code != 200:
        return []

    data = r.json()

    return [
        {
            "id": t["id"],
            "posts_count": t["posts_count"]
        }
        for t in data["topic_list"]["topics"]
    ]

def mark_post_as_read(session, topic_id, post_count):
    url = f"https://{hostUrl}/topics/timings"

    timings = {
        str(i): 60000
        for i in range(1, post_count + 1)
    }

    payload = {
        "topic_id": topic_id,
        "topic_time": post_count * 60000,
        "timings": timings
    }

    csrf = get_csrf(session)

    r = session.post(
        url,
        json=payload,
        headers={
            "X-CSRF-Token": csrf,
            "User-Agent": "Mozilla/5.0",
            "Content-Type": "application/json"
        }
    )

    print(f"[Read] {topic_id} → {r.status_code}")

    if r.status_code != 200:
        print(r.text[:300])

def tab_worker(session):
    page = 1

    while True:
        topics = load_topics(session, page)

        if not topics:
            break

        for t in topics:
            mark_post_as_read(
                session,
                t["id"],
                t["posts_count"]
            )

            time.sleep(0.4)

        page += 1

```

---

<div class="post-metadata">

### Author: ![kittenwater](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kittenwater/32/541219_2.png) [@kittenwater](https://meta.discourse.org/u/kittenwater)
#### Post date: [February 3, 2026, 11:11pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/2 "2026-02-03T23:11:46Z")

</div>

I’m bumping this because I still need the answer.

Thanks

---

<div class="post-metadata">

### Author: ![Canapin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/canapin/32/119591_2.png) [@Canapin](https://meta.discourse.org/u/Canapin)
#### Post date: [February 3, 2026, 11:54pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/3 "2026-02-03T23:54:41Z")

</div>

> [@kittenwater](#):
>
> `"timings": timings`

What if you send this as a flat `"timings[post_number]": duration`? Does it work?

If I send a request containing that payload:

```json
{
  "timings[1]": 10000,
  "topic_time": 10000,
  "topic_id": 6,
}

```

It updates the timing tables and the post is marked as read, and `/activity/read` is updated as well.

Why are you trying to do that? What is the purpose?

---

<div class="post-metadata">

### Author: ![kittenwater](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kittenwater/32/541219_2.png) [@kittenwater](https://meta.discourse.org/u/kittenwater)
#### Post date: [February 4, 2026, 11:10am UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/4 "2026-02-04T11:10:16Z")

</div>

> [@Canapin](#):
>
> What if you send this as a flat `"timings[post_number]": duration`? Does it work?

It doesn’t seem to exactly work.

Here is the code snippet which I modified.

```python
def mark_post_as_read(session, topic_id, post_count):
    url = f"https://{hostUrl}/topics/timings"

    payload = {
        "topic_id": topic_id,
        "topic_time": post_count * 60000
    }

    for i in range(1, post_count):
        payload[f"timings[{i}]"] = 60000

    csrf = get_csrf(session)

    r = session.post(
        url,
        json=payload,
        headers={
            "X-CSRF-Token": csrf,
            "User-Agent": "Mozilla/5.0",
            "Content-Type": "application/json"
        }
    )

    print(f"[Read] {topic_id} → {r.status_code}")

    if r.status_code != 200:
        print(r.text[:300])

```

It still returns 200 but isn’t updated.

Thanks

---

<div class="post-metadata">

### Author: ![Canapin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/canapin/32/119591_2.png) [@Canapin](https://meta.discourse.org/u/Canapin)
#### Post date: [February 5, 2026, 1:08pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/5 "2026-02-05T13:08:33Z")

</div>

The code looks fine, I’m not sure where the issue comes from. 😕  
My guts tell me we are just missing something obvious somewhere 😅

---

<div class="post-metadata">

### Author: ![Canapin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/canapin/32/119591_2.png) [@Canapin](https://meta.discourse.org/u/Canapin)
#### Post date: [February 5, 2026, 11:01pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/7 "2026-02-05T23:01:27Z")

</div>

This works:

```py
def load_topics(session, page):
    print(f"[Topics] Loading page {page}")
    r = session.get(f"https://{hostUrl}/latest.json?page={page}")
    if r.status_code != 200:
        return []
    return [{"id": t["id"], "posts_count": t["posts_count"]} for t in r.json()["topic_list"]["topics"]]

```

```py
    timings = {
        str(i): 60000
        for i in range(1, post_count + 1)
    }
    payload = {
        "topic_id": topic_id,
        "topic_time": post_count * 60000,
        "timings": timings 
    }    

    # Use json=payload to send as application/json
    r = session.post(url, json=payload, headers = {
        "X-CSRF-Token": csrf,
        "User-Agent": "Mozilla/5.0",
        "X-Requested-With": "XMLHttpRequest",
        "Content-Type": "application/json"
      }
    )

```

---

<div class="post-metadata">

### Author: ![kittenwater](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kittenwater/32/541219_2.png) [@kittenwater](https://meta.discourse.org/u/kittenwater)
#### Post date: [February 6, 2026, 2:44am UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/8 "2026-02-06T02:44:22Z")

</div>

Thank you so MUCH!!!

This finally works.

---

<div class="post-metadata">

### Author: ![kittenwater](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kittenwater/32/541219_2.png) [@kittenwater](https://meta.discourse.org/u/kittenwater)
#### Post date: [February 6, 2026, 4:16am UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/9 "2026-02-06T04:16:19Z")

</div>

Actually, one more thing.

This is pretty interesting!

1. It DOES update at the post read history ✅
2. Posts read count increases ✅

BUT:

1. Topic read count doesn’t increase ❌

These are the 2 I’m talking about!

 ![Screenshot 2026-02-05 at 11.12.14 PM](https://global.discourse-cdn.com/meta/original/4X/f/7/7/f77194348a432de24704052cbd38fc16ebeb768f.png)

Pretty interesting.

---

<div class="post-metadata">

### Author: ![Canapin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/canapin/32/119591_2.png) [@Canapin](https://meta.discourse.org/u/Canapin)
#### Post date: [February 6, 2026, 11:02am UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/10 "2026-02-06T11:02:30Z")

</div>

I wouldn’t be surprised if those stats were updated by a regular sidekick job, for performance reasons.

What’s your use case to update timings with a script?

---

<div class="post-metadata">

### Author: ![kittenwater](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kittenwater/32/541219_2.png) [@kittenwater](https://meta.discourse.org/u/kittenwater)
#### Post date: [February 6, 2026, 8:55pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/11 "2026-02-06T20:55:38Z")

</div>

> [@Canapin](#):
>
> I wouldn’t be surprised if those stats were updated by a regular sidekick job, for performance reasons.

I can say with 100% certainty that this statement is _true_!

The posts read has updated several times but the topics read hasn’t. Are they on different intervals? It’s been ~20 hours since and the posts read count keeps increasing but the topics read doesn’t.

I just want to try to reverse engineer the endpoints! It’s cool.

I think I should wait a bit before coming back and seeing if the values have changed

---

<div class="post-metadata">

### Author: ![Canapin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/canapin/32/119591_2.png) [@Canapin](https://meta.discourse.org/u/Canapin)
#### Post date: [February 6, 2026, 9:12pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/12 "2026-02-06T21:12:52Z")

</div>

> [@kittenwater](#):
>
> I think I should wait a bit before coming back and seeing if the values have changed

You can trigger the sidekiq job manually in `/sidekiq/scheduler` if you find which one it is 🙂

---

<div class="post-metadata">

### Author: ![Canapin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/canapin/32/119591_2.png) [@Canapin](https://meta.discourse.org/u/Canapin)
#### Post date: [February 6, 2026, 9:25pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/13 "2026-02-06T21:25:00Z")

</div>

Perhaps it’s `Jobs::DirectoryRefreshDaily`.

---

<div class="post-metadata">

### Author: ![Moin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/moin/32/554653_2.png) [@Moin](https://meta.discourse.org/u/Moin)
#### Post date: [February 6, 2026, 9:28pm UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/14 "2026-02-06T21:28:38Z")

</div>

> [@kittenwater](#):
>
> These are the 2 I’m talking about!
> 
> ![Screenshot 2026-02-05 at 11.12.14 PM](https://global.discourse-cdn.com/meta/original/4X/f/7/7/f77194348a432de24704052cbd38fc16ebeb768f.png)

Do you see the same in the user directory at `/u?period=daily` or weekly? There, you can see when the numbers were updated at the top.  
 ![image](https://global.discourse-cdn.com/meta/original/4X/a/d/6/ad6485cd2f33c279f3f3b49a81f9eadb8cbc347a.png)

I think the numbers for “today” are updated once per hour, while the other timespans are updated only once per day.

---

<div class="post-metadata">

### Author: ![kittenwater](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kittenwater/32/541219_2.png) [@kittenwater](https://meta.discourse.org/u/kittenwater)
#### Post date: [February 7, 2026, 12:48am UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/15 "2026-02-07T00:48:58Z")

</div>

@Canapin, I am not the owner of the website. If I can still trigger it from just being logged-in as a normal user, let me know the method to do so.

@Moin

The site I’m using has that disabled and will always return “A list of community members showing their activity will be shown here. For now the list is empty because your community is still brand new!”

---

<div class="post-metadata">

### Author: ![Canapin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/canapin/32/119591_2.png) [@Canapin](https://meta.discourse.org/u/Canapin)
#### Post date: [February 7, 2026, 10:45am UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/16 "2026-02-07T10:45:05Z")

</div>

> [@kittenwater](#):
>
> I am not the owner of the website. If I can still trigger it from just being logged in as a normal user, let me know the method to do so.

In his case you can’t. Being a regular user isn’t ideal to reverse-engineer the API.  
If you can, try a local dev install or a production install on a cheap VPS (a 3-4$ server is alright), since Discourse doesn’t require a hostname or SMTP anymore.

---

<div class="post-metadata">

### Author: ![kittenwater](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kittenwater/32/541219_2.png) [@kittenwater](https://meta.discourse.org/u/kittenwater)
#### Post date: [February 8, 2026, 1:31am UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/17 "2026-02-08T01:31:27Z")

</div>

Hey @Canapin , thanks for continuously helping.

How would would I do that? It currently says **22 topics read** and **2.6M posts read**

---

<div class="post-metadata">

### Author: ![system](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/system/32/443519_2.png) [@system](https://meta.discourse.org/u/system)
#### Post date: [March 10, 2026, 1:31am UTC](https://meta.discourse.org/t/mark-posts-in-topic-as-read/394852/18 "2026-03-10T01:31:53Z")

</div>

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.
